Преглед на файлове

Merge pull request #1797 from PrivateBin/js/removeJQuery

refactor: remove jQuery
rugk преди 1 седмица
родител
ревизия
5bbb4e0078

+ 1 - 1
.devcontainer/devcontainer.json

@@ -36,7 +36,7 @@
         }
     },
     "features": {
-        "ghcr.io/devcontainers-contrib/features/mocha:2": {},
+        "ghcr.io/devcontainers-extra/features/mocha:2": {},
         "ghcr.io/yassinedoghri/devcontainers/php-extensions-installer:1": {
             "extensions": "gd"
         }

+ 1 - 0
.gitattributes

@@ -9,6 +9,7 @@ js/.nycrc.yml export-ignore
 js/common.js export-ignore
 js/eslint.config.js export-ignore
 js/test/ export-ignore
+js/jsconfig.json export-ignore
 .codeclimate.yml export-ignore
 .csslintrc export-ignore
 .devcontainer export-ignore

+ 1 - 1
.github/copilot-instructions.md

@@ -38,7 +38,7 @@
 
 - **No explicit build step** for PHP. The web app is served directly from source.
   - This means **composer directory** need to be comitted (_except_ of big optional dependences like Google Cloud like GCS support or similar!)
-- **For JavaScript:** There is no webpack/bundler step for release; browser JS is written in compatible ES6+ syntax, except in `legacy.js` (which must be designed to run cleanly even on ancient IE4 or Netscape to display the error message that a browser upgrade is necessary). We are trying to avoid jQuery in any new code and would like to eventually drop use of jQuery. We are considering modularizing the JS logic, but need to ensure to do so in a way that will work both in the browser as well as for node JS driven unit tests.
+- **For JavaScript:** There is no webpack/bundler step for release; browser JS is written in compatible ES6+ syntax, except in `legacy.js` (which must be designed to run cleanly even on ancient IE4 or Netscape to display the error message that a browser upgrade is necessary). We dropped use of jQuery. We are considering modularizing the JS logic, but need to ensure to do so in a way that will work both in the browser as well as for node JS driven unit tests.
 
 ### Run
 

+ 5 - 6
.jshintrc

@@ -2,7 +2,7 @@
     "bitwise": true,
     "curly": true,
     "eqeqeq": true,
-    "esversion": 6,
+    "esversion": 2018,
     "forin": true,
     "freeze": true,
     "futurehostile": true,
@@ -17,11 +17,12 @@
     "quotmark": "single",
     "singleGroups": true,
     "strict": true,
+    "globalstrict": true,
     "undef": true,
     "unused": true,
-    "jquery": true,
+    "jquery": false,
     "browser": true,
-    "predef": {
+    "globals": {
       "after": true,
       "before": true,
       "cleanup": true,
@@ -36,9 +37,7 @@
       "jsdom": true,
       "require": false,
       "setTimeout": false,
-      "window": true
-    },
-    "globals": {
+      "window": true,
       "DOMPurify": true,
       "kjua": true
     }

+ 15 - 0
.run/Frontend tests.run.xml

@@ -0,0 +1,15 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="Frontend tests" type="mocha-javascript-test-runner">
+    <node-interpreter>project</node-interpreter>
+    <node-options />
+    <mocha-package>$PROJECT_DIR$/js/node_modules/mocha</mocha-package>
+    <working-directory>$PROJECT_DIR$/js</working-directory>
+    <pass-parent-env>true</pass-parent-env>
+    <ui>bdd</ui>
+    <extra-mocha-options />
+    <test-kind>DIRECTORY</test-kind>
+    <test-directory>$PROJECT_DIR$/js/test</test-directory>
+    <recursive>true</recursive>
+    <method v="2" />
+  </configuration>
+</component>

+ 6 - 0
CHANGELOG.md

@@ -1,5 +1,11 @@
 # PrivateBin version history
 
+## 2.1.0 (not yet released)
+* CHANGED: We removed jQuery in the Frontend and replaced it with vanilla JS.
+* CHANGED: Removed the unmaintained js-verify and replaced it with fast-check library.
+* CHANGED: Added a `jsconfig.json` in order to check the types of JavaScript.
+* CHANGED: Removed support for Bootstrap 3 as it requires jQuery.
+
 ## 2.0.6 (not yet released)
 * CHANGED: Upgrading libraries to: DOMpurify 3.4.12
 * CHANGED: Switch to PHP native JsonException type

+ 1 - 21
bin/configuration-test-generator

@@ -183,27 +183,7 @@ new ConfigurationTestGenerator(array(
                 ),
             ),
             'affects' => $vd,
-        ), array(
-            'setting' => 'bootstrap',
-            'tests'   => array(
-                array(
-                    'type' => 'DoesNotMatchRegularExpression',
-                    'args' => array(
-                        '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap5/privatebin\.css\\?\d[\d\.]+\d+"[^>]*/>#',
-                        '$content',
-                        'removes "bootstrap5" stylesheet correctly',
-                    ),
-                ), array(
-                    'type' => 'MatchesRegularExpression',
-                    'args' => array(
-                        '#<link[^>]+type="text/css"[^>]+rel="stylesheet"[^>]+href="css/bootstrap/bootstrap-\d[\d\.]+\d\.css"[^>]*/>#',
-                        '$content',
-                        'outputs "bootstrap" stylesheet correctly',
-                    ),
-                ),
-            ),
-            'affects' => $vd,
-        ),
+        )
     ),
     'main/sizelimit' => array(
         array(

+ 3 - 11
cfg/conf.sample.php

@@ -49,17 +49,9 @@ templateselection = false
 
 ; List of available for selection templates when "templateselection" option is enabled
 availabletemplates[] = "bootstrap5"
-availabletemplates[] = "bootstrap"
-availabletemplates[] = "bootstrap-page"
-availabletemplates[] = "bootstrap-dark"
-availabletemplates[] = "bootstrap-dark-page"
-availabletemplates[] = "bootstrap-compact"
-availabletemplates[] = "bootstrap-compact-page"
-
-; set the template your installs defaults to, defaults to "bootstrap5" (tpl/bootstrap5.php), also
-; bootstrap template (tpl/bootstrap.php) and it's variants: "bootstrap-dark", "bootstrap-compact", "bootstrap-page",
-; which can be combined with "-dark" and "-compact" for "bootstrap-dark-page",
-; "bootstrap-compact-page" - previews at:
+
+; set the template your installs defaults to, defaults to "bootstrap5" (tpl/bootstrap5.php)
+; previews at:
 ; https://privatebin.info/screenshots.html
 ; template = "bootstrap5"
 

+ 0 - 2
js/.istanbul.yml

@@ -1,7 +1,5 @@
 ---
 instrumentation:
-  excludes:
-    - jquery-3.7.1.js
   baseline-file: ../tst/log/js-coverage-baseline.json
 reporting:
   dir: ../tst/log/js-coverage-report

Файловите разлики са ограничени, защото са твърде много
+ 0 - 5
js/bootstrap-3.4.1.js


+ 51 - 70
js/common.js

@@ -2,23 +2,40 @@
 
 // testing prerequisites
 global.assert = require('assert');
-global.jsc = require('jsverify');
+const fc = require('fast-check');
 global.jsdom = require('jsdom-global');
-global.cleanup = global.jsdom();
+// initial DOM environment created by jsdom-global
+let currentCleanup = global.jsdom();
+
+// Recreates the jsdom environment and reloads privatebin.js into it.
+// The reload is necessary because modules close over DOM element references
+// in their init() methods, and pending async callbacks (e.g. setTimeout in
+// CopyToClipboard) would otherwise fire against elements from a dead window.
+global.cleanup = function (...args) {
+    if (typeof currentCleanup === 'function') {
+        currentCleanup();
+    }
+    currentCleanup = global.jsdom(...args);
+    delete require.cache[require.resolve('./privatebin')];
+    delete require.cache[require.resolve('./legacy')];
+    require('./privatebin');
+    global.PrivateBin = window.PrivateBin;
+    return global.cleanup;
+};
 global.fs = require('fs');
 global.WebCrypto = require('@peculiar/webcrypto').Crypto;
 
 // application libraries to test
-global.$ = global.jQuery = require('./jquery-3.7.1');
 global.zlib = require('./zlib').zlib;
 require('./prettify');
-global.prettyPrint = window.PR.prettyPrint;
-global.prettyPrintOne = window.PR.prettyPrintOne;
+global.prettyPrint = window.PR ? window.PR.prettyPrint : function() {};
+global.prettyPrintOne = window.PR ? window.PR.prettyPrintOne : function() {};
 global.showdown = require('./showdown-2.1.0');
 global.DOMPurify = require('./purify-3.4.12');
 global.baseX = require('./base-x-5.0.1').baseX;
 global.Legacy = require('./legacy').Legacy;
 require('./privatebin');
+global.PrivateBin = window.PrivateBin;
 
 // internal variables
 var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
@@ -35,45 +52,8 @@ var a2zString    = ['a','b','c','d','e','f','g','h','i','j','k','l','m',
     ),
     schemas = ['ftp','http','https'],
     supportedLanguages = ['ar', 'bg', 'ca', 'co', 'cs', 'de', 'el', 'es', 'et', 'fi', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'jbo', 'lt', 'no', 'nl', 'pl', 'pt', 'oc', 'ru', 'sk', 'sl', 'th', 'tr', 'uk', 'zh'],
-    mimeTypes = ['image/png', 'application/octet-stream'],
-    formats = ['plaintext', 'markdown', 'syntaxhighlighting'],
-    mimeFile = fs.createReadStream('/etc/mime.types'),
-    mimeLine = '';
-
-// populate mime types from environment
-mimeFile.on('data', function(data) {
-    mimeLine += data;
-    var index = mimeLine.indexOf('\n');
-    while (index > -1) {
-        var line = mimeLine.substring(0, index);
-        mimeLine = mimeLine.substring(index + 1);
-        parseMime(line);
-        index = mimeLine.indexOf('\n');
-    }
-});
-
-mimeFile.on('end', function() {
-    if (mimeLine.length > 0) {
-        parseMime(mimeLine);
-    }
-});
-
-function parseMime(line) {
-    // ignore comments
-    var index = line.indexOf('#');
-    if (index > -1) {
-        line = line.substring(0, index);
-    }
-
-    // ignore bits after tabs
-    index = line.indexOf('\t');
-    if (index > -1) {
-        line = line.substring(0, index);
-    }
-    if (line.length > 0) {
-        mimeTypes.push(line);
-    }
-}
+    mimeTypes = ['image/png', 'application/octet-stream'].concat(Object.keys(require('mime-db'))),
+    formats = ['plaintext', 'markdown', 'syntaxhighlighting'];
 
 // common testing helper functions
 // as of jsDOM 22 the base64 functions provided in the DOM are more restrictive
@@ -88,68 +68,68 @@ exports.btoa = function(text) {
 };
 
 // provides random lowercase characters from a to z
-exports.jscA2zString = function() {
-    return jsc.elements(a2zString);
+exports.fcA2zString = function() {
+    return fc.constantFrom(...a2zString);
 };
 
 // provides random lowercase alpha numeric characters (a to z and 0 to 9)
-exports.jscAlnumString = function() {
-    return jsc.elements(alnumString);
+exports.fcAlnumString = function() {
+    return fc.constantFrom(...alnumString);
 };
 
 //provides random characters allowed in hexadecimal notation
-exports.jscHexString = function() {
-    return jsc.elements(hexString);
+exports.fcHexString = function() {
+    return fc.constantFrom(...hexString);
 };
 
 // provides random characters allowed in GET queries
-exports.jscQueryString = function() {
-    return jsc.elements(queryString);
+exports.fcQueryString = function() {
+    return fc.constantFrom(...queryString);
 };
 
 // provides random characters allowed in hash queries
-exports.jscHashString = function() {
-    return jsc.elements(hashString);
+exports.fcHashString = function() {
+    return fc.constantFrom(...hashString);
 };
 
 // provides random characters allowed in base64 encoded strings
-exports.jscBase64String = function() {
-    return jsc.elements(base64String);
+exports.fcBase64String = function() {
+    return fc.constantFrom(...base64String);
 };
 
 // provides a random URL schema supported by the whatwg-url library
-exports.jscSchemas = function(withFtp = true) {
-    return jsc.elements(withFtp ? schemas : schemas.slice(1));
+exports.fcSchemas = function(withFtp = true) {
+    return fc.constantFrom(...(withFtp ? schemas : schemas.slice(1)));
 };
 
 // provides a random supported language string
-exports.jscSupportedLanguages = function() {
-    return jsc.elements(supportedLanguages);
+exports.fcSupportedLanguages = function() {
+    return fc.constantFrom(...supportedLanguages);
 };
 
 // provides a random mime type
-exports.jscMimeTypes = function() {
-    return jsc.elements(mimeTypes);
+exports.fcMimeTypes = function() {
+    return fc.constantFrom(...mimeTypes);
 };
 
 // provides a random PrivateBin document formatter
-exports.jscFormats = function() {
-    return jsc.elements(formats);
+exports.fcFormats = function() {
+    return fc.constantFrom(...formats);
 };
 
 // provides random URLs
-exports.jscUrl = function(withFragment = true, withQuery = true) {
+exports.fcUrl = function(withFragment = true, withQuery = true) {
     let url = {
-        schema: exports.jscSchemas(),
-        address: jsc.nearray(exports.jscA2zString()),
+        schema: exports.fcSchemas(),
+        address: fc.array(exports.fcA2zString(), {minLength: 1}),
     };
     if (withFragment) {
-        url.fragment = jsc.string;
+        url.fragment = fc.string();
     }
     if(withQuery) {
-        url.query = jsc.array(exports.jscQueryString());
+        url.query = fc.array(exports.fcQueryString());
     }
-    return jsc.record(url);
+    return fc.record(url);
 };
 
 exports.urlToString = function (url) {
@@ -159,6 +139,7 @@ exports.urlToString = function (url) {
 };
 
 exports.enableClipboard = function () {
+    // @ts-ignore
     navigator.clipboard = (function () {
         let savedText = "";
 

+ 2 - 2
js/eslint.config.js

@@ -1,5 +1,5 @@
 const globals = require('globals');
-const { globalIgnores } = require('eslint/config')
+const { globalIgnores } = require('eslint/config');
 
 module.exports = [globalIgnores(["./*.*js", "!./privatebin.js"]), {
     languageOptions: {
@@ -111,7 +111,7 @@ module.exports = [globalIgnores(["./*.*js", "!./privatebin.js"]), {
         yoda: 0,
 
         // Strict
-        strict: 0,
+        strict: 2,
 
         // Variables
         "init-declarations": 0,

Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
js/jquery-3.7.1.js


+ 22 - 0
js/jsconfig.json

@@ -0,0 +1,22 @@
+{
+  "compilerOptions": {
+    "target": "es2018",
+    "module": "NodeNext",
+    "moduleResolution": "nodenext",
+    "resolveJsonModule": true,
+    "checkJs": true,
+    "strict": true,
+    "noImplicitAny": false,
+    "forceConsistentCasingInFileNames": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "baseUrl": "./js",
+    "paths": {
+      "/*": ["./*"],
+    }
+  },
+  "exclude": [
+    "node_modules",
+    "**/node_modules/*"
+  ]
+}

+ 8 - 9
js/legacy.js

@@ -13,7 +13,6 @@
 /**
  * IMPORTANT NOTICE FOR DEVELOPERS:
  * The logic in this file is intended to run in legacy browsers. Avoid any use of:
- * - jQuery (doesn't work in older browsers)
  * - ES5 or newer in general
  * - const/let, use the traditional var declarations instead
  * - async/await or Promises, use traditional callbacks
@@ -68,26 +67,26 @@
             'Spider',
             'scraper',
             'Scraper',
-            
+
             // Search Engines
             'Mediapartners-Google',
             'BingPreview',
             'Yahoo! Slurp',
-            
+
             // SEO & Analytics
             'Screaming Frog',
-            
+
             // Social Media
             'facebookexternalhit',
-            
+
             // AI & LLM
             'ChatGPT-User',
             'anthropic-ai',
-            
+
             // Security Scanners
             'CensysInspect',
             'Shodan',
-            
+
             // Other Common Crawlers
             '80legs',
             'ia_archiver',
@@ -171,7 +170,7 @@
          * @private
          * @name   Check.isBadBot
          * @function
-         * @return {bool}
+         * @return {Boolean}
          */
         function isBadBot() {
             // check whether a bot user agent part can be found in the current
@@ -262,7 +261,7 @@
          *
          * @name   Check.getStatus
          * @function
-         * @return {bool}
+         * @return {Boolean}
          */
         me.getStatus = function()
         {

+ 322 - 0
js/package-lock.json

@@ -11,8 +11,10 @@
       "devDependencies": {
         "@peculiar/webcrypto": "^1.5.0",
         "eslint": "^9.37.0",
+        "fast-check": "^4.7.0",
         "jsdom": "^26.1.0",
         "jsdom-global": "^3.0.2",
+        "jshint": "^2.13.6",
         "jsverify": "^0.8.3",
         "mime-types": "^3.0.2",
         "mocha": "^11.7.5",
@@ -1121,6 +1123,42 @@
         "node": ">=6"
       }
     },
+    "node_modules/cli": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz",
+      "integrity": "sha512-41U72MB56TfUMGndAKK8vJ78eooOD4Z5NOL4xEfjc0c23s+6EYKXlXsmACBVclLP1yOfWCgEganVzddVrSNoTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "exit": "0.1.2",
+        "glob": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=0.2.5"
+      }
+    },
+    "node_modules/cli/node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/cliui": {
       "version": "8.0.1",
       "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1223,12 +1261,28 @@
       "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
       "dev": true
     },
+    "node_modules/console-browserify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz",
+      "integrity": "sha512-duS7VP5pvfsNLDvL1O4VOEbw37AI3A4ZUQYemvDlnpGrNu9tprR7BYWpDYwC0Xia0Zxz5ZupdiIrUp0GH1aXfg==",
+      "dev": true,
+      "dependencies": {
+        "date-now": "^0.1.4"
+      }
+    },
     "node_modules/convert-source-map": {
       "version": "1.9.0",
       "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
       "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
       "dev": true
     },
+    "node_modules/core-util-is": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+      "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1269,6 +1323,12 @@
         "node": ">=18"
       }
     },
+    "node_modules/date-now": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz",
+      "integrity": "sha512-AsElvov3LoNB7tf5k37H2jYSB+ZZPMT5sG2QjJCcdlV5chIv6htBUBUui2IKRjgtKAKtCBN7Zbwa+MtwLjSeNw==",
+      "dev": true
+    },
     "node_modules/debug": {
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1331,6 +1391,66 @@
         "node": ">=0.3.1"
       }
     },
+    "node_modules/dom-serializer": {
+      "version": "0.2.2",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
+      "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.0.1",
+        "entities": "^2.0.0"
+      }
+    },
+    "node_modules/dom-serializer/node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/dom-serializer/node_modules/entities": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+      "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
+      "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.3.0.tgz",
+      "integrity": "sha512-q9bUwjfp7Eif8jWxxxPSykdRZAb6GkguBGSgvvCrhI9wB71W2K/Kvv4E61CF/mcCfnVJDeDWx/Vb/uAqbDj6UQ==",
+      "dev": true,
+      "dependencies": {
+        "domelementtype": "1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz",
+      "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==",
+      "dev": true,
+      "dependencies": {
+        "dom-serializer": "0",
+        "domelementtype": "1"
+      }
+    },
     "node_modules/eastasianwidth": {
       "version": "0.2.0",
       "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
@@ -1547,6 +1667,38 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/exit": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+      "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/fast-check": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
+      "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "pure-rand": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=12.17.0"
+      }
+    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -1674,6 +1826,13 @@
         }
       ]
     },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/gensync": {
       "version": "1.0.0-beta.2",
       "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1828,6 +1987,27 @@
       "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
       "dev": true
     },
+    "node_modules/htmlparser2": {
+      "version": "3.8.3",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz",
+      "integrity": "sha512-hBxEg3CYXe+rPIua8ETe7tmG3XDn9B0edOE/e9wH2nLczxzgdu0m0aNHY+5wFZiviLWLdANPJTssa92dMcXQ5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "1",
+        "domhandler": "2.3",
+        "domutils": "1.5",
+        "entities": "1.0",
+        "readable-stream": "1.1"
+      }
+    },
+    "node_modules/htmlparser2/node_modules/entities": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz",
+      "integrity": "sha512-LbLqfXgJMmy81t+7c14mnulFHJ170cM6E+0vMXR9k/ZiZwgX8i5pNgjTCX3SO4VeUsFLV+8InixoretwU+MjBQ==",
+      "dev": true,
+      "license": "BSD-like"
+    },
     "node_modules/http-proxy-agent": {
       "version": "7.0.2",
       "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -1910,6 +2090,25 @@
         "node": ">=8"
       }
     },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/is-extglob": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2004,6 +2203,13 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/isarray": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
+      "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/isexe": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -2213,6 +2419,51 @@
         "node": ">=6"
       }
     },
+    "node_modules/jshint": {
+      "version": "2.13.6",
+      "resolved": "https://registry.npmjs.org/jshint/-/jshint-2.13.6.tgz",
+      "integrity": "sha512-IVdB4G0NTTeQZrBoM8C5JFVLjV2KtZ9APgybDA1MK73xb09qFs0jCXyQLnCOp1cSZZZbvhq/6mfXHUTaDkffuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cli": "~1.0.0",
+        "console-browserify": "1.1.x",
+        "exit": "0.1.x",
+        "htmlparser2": "3.8.x",
+        "lodash": "~4.17.21",
+        "minimatch": "~3.0.2",
+        "strip-json-comments": "1.0.x"
+      },
+      "bin": {
+        "jshint": "bin/jshint"
+      }
+    },
+    "node_modules/jshint/node_modules/minimatch": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz",
+      "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/jshint/node_modules/strip-json-comments": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-1.0.4.tgz",
+      "integrity": "sha512-AOPG8EBc5wAikaG1/7uFCNFJwnKOuQwFTpYBdTW6OvWHeZBQBrAA/amefHGrEiOnCPcLFZK6FUPtWVKpQVIRgg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "strip-json-comments": "cli.js"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
     "node_modules/json-buffer": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@@ -2304,6 +2555,13 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/lodash": {
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+      "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/lodash.flattendeep": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz",
@@ -2819,6 +3077,16 @@
         "node": ">=6"
       }
     },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
     "node_modules/optionator": {
       "version": "0.9.4",
       "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2942,6 +3210,16 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/path-key": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -3067,6 +3345,23 @@
         "node": ">=6"
       }
     },
+    "node_modules/pure-rand": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
+      "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/dubzzz"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fast-check"
+        }
+      ],
+      "license": "MIT"
+    },
     "node_modules/pvtsutils": {
       "version": "1.3.6",
       "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -3103,6 +3398,19 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/readable-stream": {
+      "version": "1.1.14",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
+      "integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "core-util-is": "~1.0.0",
+        "inherits": "~2.0.1",
+        "isarray": "0.0.1",
+        "string_decoder": "~0.10.x"
+      }
+    },
     "node_modules/readdirp": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3415,6 +3723,13 @@
       "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
       "dev": true
     },
+    "node_modules/string_decoder": {
+      "version": "0.10.31",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
+      "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/string-width": {
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -3974,6 +4289,13 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/write-file-atomic": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",

+ 3 - 0
js/package.json

@@ -9,14 +9,17 @@
   "devDependencies": {
     "@peculiar/webcrypto": "^1.5.0",
     "eslint": "^9.37.0",
+    "fast-check": "^4.7.0",
     "jsdom": "^26.1.0",
     "jsdom-global": "^3.0.2",
+    "jshint": "^2.13.6",
     "jsverify": "^0.8.3",
     "mime-types": "^3.0.2",
     "mocha": "^11.7.5",
     "nyc": "^18.0.0"
   },
   "scripts": {
+    "lint": "eslint",
     "test": "mocha",
     "ci-test": "mocha --reporter xunit --reporter-option output=mocha-results.xml"
   },

Файловите разлики са ограничени, защото са твърде много
+ 203 - 235
js/privatebin.js


+ 322 - 323
js/test/Alert.js

@@ -1,313 +1,313 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('Alert', function () {
     describe('showStatus', function () {
-        jsc.property(
-            'shows a status message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="status">' + message + '</div>';
-                $('body').html(
-                    '<div id="status"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="status">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="status"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows a status message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            function (message) {
-                message = message.join('');
-                const expected = '<div id="status" role="alert" ' +
-                    'class="statusmessage alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-info-sign" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info hidden"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                function (message) {
+                    message = message.join('');
+                    const expected = '<div id="status" role="alert" ' +
+                        'class="statusmessage alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-info-sign" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info hidden"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows a status message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="status" role="alert" ' +
-                    'class="statusmessage alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info hidden"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showStatus(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a status message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="status" role="alert" ' +
+                        'class="statusmessage alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info hidden"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showStatus(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('showWarning', function () {
-        jsc.property(
-            'shows a warning message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage">' + message + '</div>';
-                $('body').html(
-                    '<div id="errormessage"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows a warning message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message) {
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-warning-sign" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message) {
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-warning-sign" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows a warning message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showWarning(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows a warning message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showWarning(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('showError', function () {
-        jsc.property(
-            'shows an error message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage">' + message + '</div>';
-                $('body').html(
-                    '<div id="errormessage"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows an error message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-alert" ' +
-                    'aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-alert" ' +
+                        'aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows an error message (bootstrap, custom icon)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (icon, message) {
-                icon = icon.join('');
-                message = message.join('');
-                const expected = '<div id="errormessage" role="alert" ' +
-                    'class="statusmessage alert alert-danger"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></div>';
-                $('body').html(
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger hidden"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showError(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows an error message (bootstrap, custom icon)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (icon, message) {
+                    icon = icon.join('');
+                    message = message.join('');
+                    const expected = '<div id="errormessage" role="alert" ' +
+                        'class="statusmessage alert alert-danger"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger hidden"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showError(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('showRemaining', function () {
-        jsc.property(
-            'shows remaining time (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            'integer',
-            function (message, string, number) {
-                message = message.join('');
-                string = string.join('');
-                const expected = '<div id="remainingtime" class="">' + string + message + number + '</div>';
-                $('body').html(
-                    '<div id="remainingtime" class="hidden"></div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows remaining time (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                fc.integer(),
+                function (message, string, number) {
+                    message = message.join('');
+                    string = string.join('');
+                    const expected = '<div id="remainingtime" class="">' + string + message + number + '</div>';
+                    document.body.innerHTML =
+                        '<div id="remainingtime" class="hidden"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'shows remaining time (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            'integer',
-            function (message, string, number) {
-                message = message.join('');
-                string = string.join('');
-                const expected = '<div id="remainingtime" role="alert" ' +
-                    'class="alert alert-info"><span ' +
-                    'class="glyphicon glyphicon-fire" aria-hidden="true">' +
-                    '</span> <span>' + string + message + number + '</span></div>';
-                $('body').html(
-                    '<div id="remainingtime" role="alert" class="hidden ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-fire" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+        it('shows remaining time (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                fc.integer(),
+                function (message, string, number) {
+                    message = message.join('');
+                    string = string.join('');
+                    const expected = '<div id="remainingtime" role="alert" ' +
+                        'class="alert alert-info"><span ' +
+                        'class="glyphicon glyphicon-fire" aria-hidden="true">' +
+                        '</span> <span>' + string + message + number + '</span></div>';
+                    document.body.innerHTML =
+                        '<div id="remainingtime" role="alert" class="hidden ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-fire" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showRemaining(['%s' + message + '%d', string, number]);
+                    const result = document.body.innerHTML;
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('showLoading', function () {
-        jsc.property(
-            'shows a loading message (basic)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message, icon) {
-                message = message.join('');
-                icon = icon.join('');
-                const defaultMessage = 'Loading…';
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('shows a loading message (basic)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message, icon) {
+                    message = message.join('');
+                    icon = icon.join('');
+                    const defaultMessage = 'Loading…';
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    const expected = '<div id="loadingindicator" class="">' + message + '</div>';
+                    document.body.innerHTML =
+                        '<div id="loadingindicator" class="hidden">' + defaultMessage + '</div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showLoading(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
                 }
-                const expected = '<div id="loadingindicator" class="">' + message + '</div>';
-                $('body').html(
-                    '<div id="loadingindicator" class="hidden">' + defaultMessage + '</div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showLoading(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+            ));
+        });
 
-        jsc.property(
-            'shows a loading message (bootstrap)',
-            jsc.array(common.jscAlnumString()),
-            jsc.array(common.jscAlnumString()),
-            function (message, icon) {
-                message = message.join('');
-                icon = icon.join('');
-                const defaultMessage = 'Loading…';
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('shows a loading message (bootstrap)', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString()),
+                fc.array(common.fcAlnumString()),
+                function (message, icon) {
+                    message = message.join('');
+                    icon = icon.join('');
+                    const defaultMessage = 'Loading…';
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    const expected = '<ul class="nav navbar-nav"><li ' +
+                        'id="loadingindicator" class="navbar-text"><span ' +
+                        'class="glyphicon glyphicon-' + icon +
+                        '" aria-hidden="true"></span> <span>' + message + '</span></li></ul>';
+                    document.body.innerHTML =
+                        '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
+                        'class="navbar-text hidden"><span class="glyphicon ' +
+                        'glyphicon-time" aria-hidden="true"></span> ' +
+                        defaultMessage + '</li></ul>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.showLoading(message, icon);
+                    const result = document.body.innerHTML;
+                    return expected === result;
                 }
-                const expected = '<ul class="nav navbar-nav"><li ' +
-                    'id="loadingindicator" class="navbar-text"><span ' +
-                    'class="glyphicon glyphicon-' + icon +
-                    '" aria-hidden="true"></span> <span>' + message + '</span></li></ul>';
-                $('body').html(
-                    '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
-                    'class="navbar-text hidden"><span class="glyphicon ' +
-                    'glyphicon-time" aria-hidden="true"></span> ' +
-                    defaultMessage + '</li></ul>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.showLoading(message, icon);
-                const result = $('body').html();
-                return expected === result;
-            }
-        );
+            ));
+        });
     });
 
     describe('hideLoading', function () {
         it(
             'hides the loading message',
             function() {
-                $('body').html(
+                document.body.innerHTML =
                     '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
                     'class="navbar-text"><span class="glyphicon ' +
                     'glyphicon-time" aria-hidden="true"></span> ' +
-                    'Loading…</li></ul>'
-                );
-                $('body').addClass('loading');
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.hideLoading();
-                assert.ok(!$('body').hasClass('loading'));
-                assert.ok($('#loadingindicator').hasClass('hidden'));
+                    'Loading…</li></ul>';
+                document.body.classList.add('loading');
+                PrivateBin.Alert.init();
+                PrivateBin.Alert.hideLoading();
+                assert.ok(!document.body.classList.contains('loading'));
+                assert.ok(document.getElementById('loadingindicator').classList.contains('hidden'));
             }
         );
     });
@@ -316,64 +316,63 @@ describe('Alert', function () {
         it(
             'hides all messages',
             function() {
-                $('body').html(
+                document.body.innerHTML =
                     '<div id="status" role="alert" class="statusmessage ' +
                     'alert alert-info"><span class="glyphicon ' +
                     'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
                     '<div id="errormessage" role="alert" class="statusmessage ' +
                     'alert alert-danger"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.hideMessages();
-                assert.ok($('#status').hasClass('hidden'));
-                assert.ok($('#errormessage').hasClass('hidden'));
+                    'glyphicon-alert" aria-hidden="true"></span> </div>';
+                PrivateBin.Alert.init();
+                PrivateBin.Alert.hideMessages();
+                assert.ok(document.getElementById('status').classList.contains('hidden'));
+                assert.ok(document.getElementById('errormessage').classList.contains('hidden'));
             }
         );
     });
 
     describe('setCustomHandler', function () {
-        jsc.property(
-            'calls a given handler function',
-            'nat 3',
-            jsc.array(common.jscAlnumString()),
-            function (trigger, message) {
-                message = message.join('');
-                let handlerCalled = false,
-                    defaultMessage = 'Loading…',
-                    functions = [
-                        $.PrivateBin.Alert.showStatus,
-                        $.PrivateBin.Alert.showError,
-                        $.PrivateBin.Alert.showRemaining,
-                        $.PrivateBin.Alert.showLoading
-                    ];
-                if (message.length === 0) {
-                    message = defaultMessage;
+        it('calls a given handler function', () => {
+            fc.assert(fc.property(
+                fc.integer({min: 0, max: 3}),
+                fc.array(common.fcAlnumString()),
+                function (trigger, message) {
+                    message = message.join('');
+                    let handlerCalled = false,
+                        defaultMessage = 'Loading…',
+                        functions = [
+                            PrivateBin.Alert.showStatus,
+                            PrivateBin.Alert.showError,
+                            PrivateBin.Alert.showRemaining,
+                            PrivateBin.Alert.showLoading
+                        ];
+                    if (message.length === 0) {
+                        message = defaultMessage;
+                    }
+                    document.body.innerHTML =
+                        '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
+                        'class="navbar-text hidden"><span class="glyphicon ' +
+                        'glyphicon-time" aria-hidden="true"></span> ' +
+                        defaultMessage + '</li></ul>' +
+                        '<div id="remainingtime" role="alert" class="hidden ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-fire" aria-hidden="true"></span> </div>' +
+                        '<div id="status" role="alert" class="statusmessage ' +
+                        'alert alert-info"><span class="glyphicon ' +
+                        'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
+                        '<div id="errormessage" role="alert" class="statusmessage ' +
+                        'alert alert-danger"><span class="glyphicon ' +
+                        'glyphicon-alert" aria-hidden="true"></span> </div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.Alert.setCustomHandler(function(id, element) {
+                        handlerCalled = true;
+                        return Math.random() > 0.5 ? true : element;
+                    });
+                    functions[trigger](message);
+                    PrivateBin.Alert.setCustomHandler(null);
+                    return handlerCalled;
                 }
-                $('body').html(
-                    '<ul class="nav navbar-nav"><li id="loadingindicator" ' +
-                    'class="navbar-text hidden"><span class="glyphicon ' +
-                    'glyphicon-time" aria-hidden="true"></span> ' +
-                    defaultMessage + '</li></ul>' +
-                    '<div id="remainingtime" role="alert" class="hidden ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-fire" aria-hidden="true"></span> </div>' +
-                    '<div id="status" role="alert" class="statusmessage ' +
-                    'alert alert-info"><span class="glyphicon ' +
-                    'glyphicon-info-sign" aria-hidden="true"></span> </div>' +
-                    '<div id="errormessage" role="alert" class="statusmessage ' +
-                    'alert alert-danger"><span class="glyphicon ' +
-                    'glyphicon-alert" aria-hidden="true"></span> </div>'
-                );
-                $.PrivateBin.Alert.init();
-                $.PrivateBin.Alert.setCustomHandler(function(id, $element) {
-                    handlerCalled = true;
-                    return jsc.random(0, 1) ? true : $element;
-                });
-                functions[trigger](message);
-                $.PrivateBin.Alert.setCustomHandler(null);
-                return handlerCalled;
-            }
-        );
+            ));
+        });
     });
 });

+ 171 - 137
js/test/AttachmentViewer.js

@@ -1,140 +1,139 @@
 'use strict';
 const common = require('../common');
+const fc = require('fast-check');
 
 describe('AttachmentViewer', function () {
-    describe('setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo', function () {
+    beforeEach(() => {
+        mockCreateObjectUrl();
+    });
+
+    afterEach(() => {
+        globalThis.cleanup()
+    });
+
+    describe('whole run (setAttachment, showAttachment, removeAttachment, hideAttachment, hideAttachmentPreview, hasAttachment, getAttachment & moveAttachmentTo)', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'displays & hides data as requested',
-            common.jscMimeTypes(),
-            'string',
-            'string',
-            'string',
-            'string',
-             // eslint-disable-next-line complexity
-            function (mimeType, rawdata, filename, prefix, postfix) {
-                let clean = jsdom(),
-                    data = 'data:' + mimeType + ';base64,' + common.btoa(rawdata),
-                    mimePrefix = mimeType.substring(0, 6),
-                    previewSupported = (
-                        mimePrefix === 'image/' ||
-                        mimePrefix === 'audio/' ||
-                        mimePrefix === 'video/' ||
-                        mimeType.match(/\/pdf/i)
-                    ),
-                    results = [],
-                    result = '';
-                // text node of attachment will truncate at null byte
-                if (filename === '\u0000') {
-                    filename = '';
-                }
-                prefix  = prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
-                $('body').html(
-                    '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
-                    '<div id="attachment" class="hidden"></div>' +
-                    '<div id="templates">' +
-                        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
-                            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
-                            '<a class="alert-link">Download attachment</a>' +
-                        '</div>' +
-                    '</div>'
-                );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
+        it('displays & hides data as requested', () => {
+            fc.assert(fc.property(
+                common.fcMimeTypes(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                // eslint-disable-next-line complexity
+                function (mimeType, rawdata, filename, prefix, postfix) {
+                    let data = 'data:' + mimeType + ';base64,' + common.btoa(rawdata),
+                        mimePrefix = mimeType.substring(0, 6),
+                        previewSupported = (
+                            mimePrefix === 'image/' ||
+                            mimePrefix === 'audio/' ||
+                            mimePrefix === 'video/' ||
+                            mimeType.match(/\/pdf/i)
+                        ),
+                        results = [],
+                        result = '';
+                    // text node of attachment will truncate at null byte
+                    if (filename === '\u0000') {
+                        filename = '';
+                    }
+                    prefix  = prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%').replace(/<|>/g, '');
+                    document.body.innerHTML = (
+                        '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
+                        '<div id="attachment" class="hidden"></div>' +
+                        '<div id="templates">' +
+                            '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
+                                '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
+                                '<a class="alert-link">Download attachment</a>' +
+                            '</div>' +
+                        '</div>'
                     );
-                }
-                $.PrivateBin.AttachmentViewer.init();
-                $.PrivateBin.Model.init();
-                results.push(
-                    !$.PrivateBin.AttachmentViewer.hasAttachment() &&
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length === 0 &&
-                    $('#attachmenttemplate').hasClass('hidden') &&
-                    $('#attachmentPreview').hasClass('hidden')
-                );
-                global.atob = common.atob;
-                if (filename.length) {
-                    $.PrivateBin.AttachmentViewer.setAttachment(data, filename);
-                } else {
-                    $.PrivateBin.AttachmentViewer.setAttachment(data);
-                }
-                // // beyond this point we will get the blob URL instead of the data
-                data = window.URL.createObjectURL(data);
-                const attachment = $.PrivateBin.AttachmentViewer.getAttachments();
-                results.push(
-                    $.PrivateBin.AttachmentViewer.hasAttachment() &&
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length > 0 &&
-                    $('#attachmentPreview').hasClass('hidden') &&
-                    attachment[0][0] === data &&
-                    attachment[0][1] === filename
-                );
-                $.PrivateBin.AttachmentViewer.showAttachment();
-                results.push(
-                    !$('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length > 0 &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                $.PrivateBin.AttachmentViewer.hideAttachment();
-                results.push(
-                    $('#attachment').hasClass('hidden') &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                if (previewSupported) {
-                    $.PrivateBin.AttachmentViewer.hideAttachmentPreview();
-                    results.push($('#attachmentPreview').hasClass('hidden'));
-                }
-                $.PrivateBin.AttachmentViewer.showAttachment();
-                results.push(
-                    !$('#attachment').hasClass('hidden') &&
-                    (previewSupported ? !$('#attachmentPreview').hasClass('hidden') : $('#attachmentPreview').hasClass('hidden'))
-                );
-                let element = $('<div>');
-                $.PrivateBin.AttachmentViewer.moveAttachmentTo(element, attachment[0], prefix + '%s' + postfix);
-                // messageIDs with links get a relaxed treatment
-                if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
-                    result = $('<textarea>').text((prefix + filename + postfix)).text();
-                } else {
-                    result = DOMPurify.sanitize(
-                        prefix + $.PrivateBin.Helper.htmlEntities(filename) + postfix, {
-                            ALLOWED_TAGS: ['a', 'i', 'span'],
-                            ALLOWED_ATTR: ['href', 'id']
-                        }
+                    PrivateBin.AttachmentViewer.init();
+                    PrivateBin.Model.init();
+                    results.push(
+                        !PrivateBin.AttachmentViewer.hasAttachment() &&
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length === 0 &&
+                        document.getElementById('attachmenttemplate').classList.contains('hidden') &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden')
                     );
-                }
-                if (filename.length) {
+                    global.atob = common.atob;
+                    if (filename.length) {
+                        PrivateBin.AttachmentViewer.setAttachment(data, filename);
+                    } else {
+                        PrivateBin.AttachmentViewer.setAttachment(data);
+                    }
+                    // // beyond this point we will get the blob URL instead of the data
+                    data = window.URL.createObjectURL(data);
+                    const attachment = PrivateBin.AttachmentViewer.getAttachments();
+                    results.push(
+                        PrivateBin.AttachmentViewer.hasAttachment() &&
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length > 0 &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden') &&
+                        attachment[0][0] === data &&
+                        attachment[0][1] === filename
+                    );
+                    PrivateBin.AttachmentViewer.showAttachment();
+                    results.push(
+                        !document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length > 0 &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
+                    );
+                    PrivateBin.AttachmentViewer.hideAttachment();
                     results.push(
-                        element.find('a')[0].href === data &&
-                        element.find('a')[0].getAttribute('download') === filename &&
-                        element.find('a')[0].text === result
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
                     );
-                } else {
-                    results.push(element.find('a')[0].href === data);
+                    if (previewSupported) {
+                        PrivateBin.AttachmentViewer.hideAttachmentPreview();
+                        results.push(document.getElementById('attachmentPreview').classList.contains('hidden'));
+                    }
+                    PrivateBin.AttachmentViewer.showAttachment();
+                    results.push(
+                        !document.getElementById('attachment').classList.contains('hidden') &&
+                        (previewSupported ? !document.getElementById('attachmentPreview').classList.contains('hidden') : document.getElementById('attachmentPreview').classList.contains('hidden'))
+                    );
+                    let element = document.createElement('div');
+                    PrivateBin.AttachmentViewer.moveAttachmentTo(element, attachment[0], prefix + '%s' + postfix);
+                    // messageIDs with links get a relaxed treatment
+                    if (prefix.indexOf('<a') === -1 && postfix.indexOf('<a') === -1) {
+                        const tempTA = document.createElement('textarea');
+                        tempTA.textContent = (prefix + filename + postfix);
+                        result = tempTA.textContent;
+                    } else {
+                        result = DOMPurify.sanitize(
+                            prefix + PrivateBin.Helper.htmlEntities(filename) + postfix, {
+                                ALLOWED_TAGS: ['a', 'i', 'span'],
+                                ALLOWED_ATTR: ['href', 'id']
+                            }
+                        );
+                    }
+                    if (filename.length) {
+                        results.push(
+                            element.querySelector('a').href === data &&
+                            element.querySelector('a').getAttribute('download') === filename &&
+                            element.querySelector('a').textContent === result
+                        );
+                    } else {
+                        results.push(element.querySelector('a').href === data);
+                    }
+                    PrivateBin.AttachmentViewer.removeAttachment();
+                    results.push(
+                        document.getElementById('attachment').classList.contains('hidden') &&
+                        document.getElementById('attachment').children.length === 0 &&
+                        document.getElementById('attachmentPreview').classList.contains('hidden')
+                    );
+                    return results.every(element => element);
                 }
-                $.PrivateBin.AttachmentViewer.removeAttachment();
-                results.push(
-                    $('#attachment').hasClass('hidden') &&
-                    $('#attachment').children().length === 0 &&
-                    $('#attachmentPreview').hasClass('hidden')
-                );
-                clean();
-                return results.every(element => element);
-            }
-        );
+            ));
+        });
 
         it(
             'sanitizes file names in attachments',
             function() {
-                const clean = jsdom();
-                $('body').html(
+                document.body.innerHTML = (
                     '<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>' +
                     '<div id="attachment" class="hidden"></div>' +
                     '<div id="templates">' +
@@ -144,18 +143,8 @@ describe('AttachmentViewer', function () {
                         '</div>' +
                     '</div>'
                 );
-                // mock createObjectURL for jsDOM
-                if (typeof window.URL.createObjectURL === 'undefined') {
-                    Object.defineProperty(
-                        window.URL,
-                        'createObjectURL',
-                        {value: function(blob) {
-                            return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
-                        }}
-                    );
-                }
-                $.PrivateBin.AttachmentViewer.init();
-                $.PrivateBin.Model.init();
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
                 global.atob = common.atob;
 
                 const maliciousFileNames = [
@@ -163,12 +152,57 @@ describe('AttachmentViewer', function () {
                     '"><meta http-equiv="refresh" content="0;url=http://example.com/">.txt'
                 ];
                 for (const filename of maliciousFileNames) {
-                    $.PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
-                    assert.ok(!$('body').html().includes(filename));
+                    PrivateBin.AttachmentViewer.setAttachment('data:;base64,', filename);
+                    assert.ok(!document.body.innerHTML.includes(filename));
                 }
-                clean();
             }
         );
+    });
 
+    describe('showAttachment()', function () {
+        it('displays attachment even when attachmentPreview element is missing',
+            function() {
+                document.body.innerHTML = (
+                    '<div id="attachment" class="hidden"></div>' +
+                    '<div id="templates">' +
+                        '<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">' +
+                            '<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>' +
+                            '<a class="alert-link">Download attachment</a>' +
+                        '</div>' +
+                    '</div>'
+                );
+                // Note: attachmentPreview element is intentionally NOT created
+
+                PrivateBin.AttachmentViewer.init();
+                PrivateBin.Model.init();
+                global.atob = common.atob;
+
+                // Set attachment without preview element
+                PrivateBin.AttachmentViewer.setAttachment('data:text/plain;base64,', 'test.txt');
+
+                // Show attachment should work even without attachmentPreview
+                PrivateBin.AttachmentViewer.showAttachment();
+
+                const attachment = document.getElementById('attachment');
+                assert.ok(!attachment.classList.contains('hidden'), 'Attachment should be visible');
+                assert.ok(attachment.children.length > 0, 'Attachment should have content');
+            }
+        )
     });
+
+    function mockCreateObjectUrl() {
+        if (typeof window.URL.createObjectURL === 'undefined') {
+            Object.defineProperty(
+                window.URL,
+                'createObjectURL',
+                {
+                    value: function (_blob) {
+                        return 'blob:' + location.origin + '/1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed';
+                    }
+                }
+            );
+        }
+    }
 });
+
+

+ 68 - 63
js/test/Check.js

@@ -1,18 +1,19 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 /* global Legacy, WebCrypto */
 
 describe('Check', function () {
     describe('init', function () {
         this.timeout(30000);
 
-        it('returns false and shows error, if a bot UA is detected', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                jsc.elements(['Bot', 'bot']),
-                'string',
+        it('returns false and shows error, if a bot UA is detected', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.constantFrom('Bot', 'bot'),
+                fc.string(),
                 function (prefix, botBit, suffix) {
-                    const clean = jsdom(
+                    const clean = globalThis.cleanup(
                         '<html><body><div id="errormessage" class="hidden"></div>' +
                         '</body></html>', {
                             'userAgent': prefix + botBit + suffix
@@ -24,66 +25,70 @@ describe('Check', function () {
                     return result;
                 }
             ),
-            {tests: 10});
+            {numRuns: 10});
         });
 
-        jsc.property(
-            'shows error, if no webcrypto is detected',
-            'bool',
-            jsc.elements(['localhost', '127.0.0.1', '[::1]', '']),
-            jsc.nearray(common.jscA2zString()),
-            jsc.elements(['.onion', '.i2p', '']),
-            function (secureProtocol, localhost, domain, tld) {
-                const isDomain = localhost === '',
-                      isSecureContext = secureProtocol || !isDomain || tld.length > 0,
-                      clean = jsdom(
-                          '<html><body><div id="errormessage" class="hidden"></div>' +
-                          '<div id="oldnotice" class="hidden"></div>' +
-                          '<div id="insecurecontextnotice" class="hidden"></div></body></html>',
-                          {
-                              'url': (secureProtocol ? 'https' : 'http' ) + '://' +
-                                     (isDomain ? domain.join('') + tld : localhost) + '/'
-                          }
-                      );
-                Legacy.Check.init();
-                const result1 = Legacy.Check.getInit() && !Legacy.Check.getStatus(),
-                      result2 = isSecureContext === (document.getElementById('insecurecontextnotice').className === 'hidden'),
-                      result3 = (document.getElementById('oldnotice').className !== 'hidden');
-                clean();
-                if (result1 && result2 && result3) {
-                    return true;
+        it('shows error, if no webcrypto is detected', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.constantFrom('localhost', '127.0.0.1', '[::1]', ''),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                fc.constantFrom('.onion', '.i2p', ''),
+                function (secureProtocol, localhost, domain, tld) {
+                    const isDomain = localhost === '',
+                          isSecureContext = secureProtocol || !isDomain || tld.length > 0,
+                          clean = globalThis.cleanup(
+                              '<html><body><div id="errormessage" class="hidden"></div>' +
+                              '<div id="oldnotice" class="hidden"></div>' +
+                              '<div id="insecurecontextnotice" class="hidden"></div></body></html>',
+                              {
+                                  'url': (secureProtocol ? 'https' : 'http' ) + '://' +
+                                         (isDomain ? domain.join('') + tld : localhost) + '/'
+                              }
+                          );
+                    Legacy.Check.init();
+                    const result1 = Legacy.Check.getInit() && !Legacy.Check.getStatus(),
+                          result2 = isSecureContext === (document.getElementById('insecurecontextnotice').className === 'hidden'),
+                          result3 = (document.getElementById('oldnotice').className !== 'hidden');
+                    clean();
+                    if (result1 && result2 && result3) {
+                        return true;
+                    }
+                    console.log(result1, result2, result3);
+                    return false;
                 }
-                console.log(result1, result2, result3);
-                return false;
-            }
-        );
+            ));
+        });
 
-        jsc.property(
-            'shows error, if HTTP only site is detected',
-            'bool',
-            jsc.nearray(common.jscA2zString()),
-            function (secureProtocol, domain) {
-                const clean = jsdom(
-                          '<html><body><div id="httpnotice" class="hidden"></div>' +
-                          '</body></html>',
-                          {
-                              'url': (secureProtocol ? 'https' : 'http' ) + '://' + domain.join('') + '/'
-                          }
-                      );
-                Object.defineProperty(window, 'crypto', {
-                    value: new WebCrypto(),
-                    writeable: false
-                });
-                Legacy.Check.init();
-                const result1 = Legacy.Check.getInit() && Legacy.Check.getStatus(),
-                      result2 = secureProtocol === (document.getElementById('httpnotice').className === 'hidden');
-                clean();
-                if (result1 && result2) {
-                    return true;
+        it('shows error, if HTTP only site is detected', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                function (secureProtocol, domain) {
+                    const clean = globalThis.cleanup(
+                              '<html><body><div id="httpnotice" class="hidden"></div>' +
+                              '</body></html>',
+                              {
+                                  'url': (secureProtocol ? 'https' : 'http' ) + '://' + domain.join('') + '/'
+                              }
+                          );
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    Legacy.Check.init();
+                    const result1 = Legacy.Check.getInit() && Legacy.Check.getStatus(),
+                          result2 = secureProtocol === (document.getElementById('httpnotice').className === 'hidden');
+                    clean();
+                    if (result1 && result2) {
+                        return true;
+                    }
+                    console.log(result1, result2);
+                    return false;
                 }
-                console.log(result1, result2);
-                return false;
-            }
-        );
+            ));
+        });
     });
 });

+ 142 - 116
js/test/CopyToClipboard.js

@@ -1,141 +1,167 @@
 'use strict';
 const common = require('../common');
+const fc = require('fast-check');
 
-describe('CopyToClipboard', function() {
-    this.timeout(30000);
-
-    describe ('Copy document to clipboard', function () {
-        jsc.property('Copy with button click',
-            common.jscFormats(),
-            'nestring',
-            async function (format, text) {
-                var clean = jsdom();
-                common.enableClipboard();
-
-                $('body').html(
-                    '<div id="placeholder"></div>' +
-					'<div id="attachmentPreview" class="hidden"></div>' +
-                    '<h5 id="copyShortcutHint" class="hidden">' +
-						'<small id="copyShortcutHintText"></small>' +
-						'<button type="button" id="copyShortcutHintBtn"></button>' +
-					'</h5>' +
-                    '<div id="prettymessage" class="hidden">' +
-						'<pre id="prettyprint"></pre>' +
-					'</div>'
-                );
-
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-
-                $.PrivateBin.CopyToClipboard.init();
-
-                $('#copyShortcutHintBtn').trigger('click');
-
-                const savedToClipboardText = await navigator.clipboard.readText();
-
-                clean();
+describe('CopyToClipboard', function () {
+    afterEach(() => {
+        globalThis.cleanup();
+    });
 
-                return text === savedToClipboardText;
-            }
-        );
+    describe('Copy document to clipboard', function () {
+        it('Copy with button click', async function () {
+            await fc.assert(fc.asyncProperty(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                async function (format, text) {
+                    common.enableClipboard();
+
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        '<div id="placeholder" class="hidden">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden">' +
+                        '<button type="button" id="prettyMessageCopyBtn"><svg id="copyIcon"></svg>' +
+                        '<svg id="copySuccessIcon"></svg></button><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>' +
+                        '<h5 id="copyShortcutHint" class="hidden">' +
+                        '<button type="button" id="copyShortcutHintBtn"></button>' +
+                        '</h5>'
+                    );
+
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+
+                    PrivateBin.CopyToClipboard.init();
+
+                    document.getElementById('copyShortcutHintBtn').click();
+
+                    const savedToClipboardText = await navigator.clipboard.readText();
+
+                    return text === savedToClipboardText;
+                }
+            ));
+        });
 
         /**
-         * Unfortunately in JSVerify impossible to check if copy with shortcut when user selected some text on the page
+         * Unfortunately, in JSVerify impossible to check if copy with shortcut when user selected some text on the page
          * (the copy document to clipboard should not work in this case) due to lacking window.getSelection() in jsdom.
          */
-        jsc.property('Copy with keyboard shortcut',
-            common.jscFormats(),
-            'nestring',
-            async function (format, text) {
-                var clean = jsdom();
-                common.enableClipboard();
-
-                $('body').html(
-                    '<div id="placeholder"></div>' +
-					'<div id="attachmentPreview" class="hidden"></div>' +
-                    '<h5 id="copyShortcutHint" class="hidden">' +
-						'<small id="copyShortcutHintText"></small>' +
-						'<button type="button" id="copyShortcutHintBtn"></button>' +
-					'</h5>' +
-                    '<div id="prettymessage" class="hidden">' +
-						'<pre id="prettyprint"></pre>' +
-					'</div>'
-                );
-
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-
-                $.PrivateBin.CopyToClipboard.init();
+        it('Copy with keyboard shortcut', async function () {
+            await fc.assert(fc.asyncProperty(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                async function (format, text) {
+                    common.enableClipboard();
+
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        '<div id="placeholder">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden">' +
+                        '<button type="button" id="prettyMessageCopyBtn"><svg id="copyIcon"></svg>' +
+                        '<svg id="copySuccessIcon"></svg></button><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>' +
+                        '<h5 id="copyShortcutHint" class="hidden">' +
+                        '<button type="button" id="copyShortcutHintBtn"></button>' +
+                        '</h5>'
+                    );
+
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+
+                    PrivateBin.CopyToClipboard.init();
+
+                    document.body.dispatchEvent(getClipboardEvent());
+
+                    const copiedTextWithoutSelectedText = await navigator.clipboard.readText();
+
+                    return copiedTextWithoutSelectedText === text;
+                }
+            ));
+        });
 
-                $('body').trigger('copy');
-
-                const copiedTextWithoutSelectedText = await navigator.clipboard.readText();
-
-                clean();
-
-                return copiedTextWithoutSelectedText === text;
+        /**
+         * ClipboardEvent is not supported in jsdom yet, so this creates a mock event to trigger the copy event listener.
+         *
+         * See https://github.com/jsdom/jsdom/issues/1568
+         *
+         * @returns {ClipboardEvent}
+         */
+        function getClipboardEvent() {
+            /** {@type ClipboardEvent} */
+            const clipboardEvent = new Event('copy', {
+                bubbles: true,
+                cancelable: true,
+                composed: true
+            });
+            clipboardEvent['clipboardData'] = {
+                getData: function () {
+                    return '';
+                }
             }
-        );
-    });
-
-
-    jsc.property('Copy link to clipboard',
-        'nestring',
-        async function (text) {
-            var clean = jsdom();
-            common.enableClipboard();
-
-            $('body').html('<button id="copyLink"></button>');
-
-            $.PrivateBin.CopyToClipboard.init();
-            $.PrivateBin.CopyToClipboard.setUrl(text);
-
-            $('#copyLink').trigger('click');
-
-            const copiedText = await navigator.clipboard.readText();
-
-            clean();
-
-            return text === copiedText;
+            return clipboardEvent;
         }
-    );
+    });
 
 
-    describe('Keyboard shortcut hint', function () {
-        jsc.property('Show hint', function () {
-                var clean = jsdom();
+    it('Copy link to clipboard', async function () {
+        await fc.assert(fc.asyncProperty(fc.string(),
+            async function (text) {
+                common.enableClipboard();
 
-                $('body').html('<h5 id="copyShortcutHint" class="hidden"></h5>');
+                document.body.innerHTML = (
+                    '<div id="status" class="hidden"></div>' +
+                    '<div id="errormessage" class="hidden"></div>' +
+                    '<button id="copyLink"></button>'
+                );
 
-                $.PrivateBin.CopyToClipboard.init();
-                $.PrivateBin.CopyToClipboard.showKeyboardShortcutHint();
+                PrivateBin.Alert.init();
+                PrivateBin.CopyToClipboard.init();
+                PrivateBin.CopyToClipboard.setUrl(text);
 
-                const hasHidden = $('#copyShortcutHint').hasClass('hidden');
+                document.getElementById('copyLink').click();
 
-                clean();
+                const copiedText = await navigator.clipboard.readText();
 
-                return !hasHidden;
-            }
+                return text === copiedText;
+            })
         );
+    });
 
-        jsc.property('Hide hint', function () {
-                var clean = jsdom();
-
-                $('body').html('<h5 id="copyShortcutHint"></h5>');
-
-                $.PrivateBin.CopyToClipboard.init();
-                $.PrivateBin.CopyToClipboard.hideKeyboardShortcutHint();
+    describe('Keyboard shortcut hint', function () {
+        it('shows hint', () => {
+            fc.assert(fc.property(fc.string(),
+                function () {
+                    document.body.innerHTML = '<h5 id="copyShortcutHint" class="hidden"></h5>';
+
+                    PrivateBin.CopyToClipboard.init();
+                    PrivateBin.CopyToClipboard.showKeyboardShortcutHint();
+
+                    return !document.getElementById('copyShortcutHint').classList.contains('hidden');
+                }
+            ));
+        });
+    });
 
-                const hasHidden = $('#copyShortcutHint').hasClass('hidden');
+    it('Hide hint', () => {
+        fc.assert(fc.property(
+            fc.string({minLength: 1}),
+            function () {
+                document.body.innerHTML = '<h5 id="copyShortcutHint"></h5>';
 
-                clean();
+                PrivateBin.CopyToClipboard.init();
+                PrivateBin.CopyToClipboard.hideKeyboardShortcutHint();
 
-                return hasHidden;
+                return document.getElementById('copyShortcutHint').classList.contains('hidden');
             }
-        );
+        ));
     });
-});
+});

+ 46 - 34
js/test/CryptTool.js

@@ -1,5 +1,7 @@
 'use strict';
 const common = require('../common');
+const fc = require('fast-check');
+const fs = require('fs');
 
 describe('CryptTool', function () {
     describe('cipher & decipher', function () {
@@ -9,26 +11,28 @@ describe('CryptTool', function () {
         });
 
         this.timeout(30000);
-        it('can en- and decrypt any message', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
-                'string',
+        it('can en- and decrypt any message', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
+                fc.string(),
                 async function (key, password, message) {
-                    const clean = jsdom();
+                    const clean = globalThis.cleanup();
                     // ensure zlib is getting loaded
-                    $.PrivateBin.Controller.initZ();
+                    PrivateBin.Controller.initZlib();
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
                     global.atob = common.atob;
                     global.btoa = common.btoa;
                     message = message.trim();
-                    const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+                    const cipherMessage = await PrivateBin.CryptTool.cipher(
                             key, password, message, []
                         ),
-                        plaintext = await $.PrivateBin.CryptTool.decipher(
+                        plaintext = await PrivateBin.CryptTool.decipher(
                             key, password, cipherMessage
                         );
                     clean();
@@ -37,24 +41,26 @@ describe('CryptTool', function () {
                     return result;
                 }
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
 
         it('does not truncate messages', async function () {
             const message = fs.readFileSync('test/compression-sample.txt', 'ascii').trim(),
-                clean = jsdom();
+                clean = globalThis.cleanup();
             Object.defineProperty(window, 'crypto', {
                 value: new WebCrypto(),
-                writeable: false
+                configurable: true,
+                enumerable: true,
+                writable: false
             });
             // ensure zlib is getting loaded
-            $.PrivateBin.Controller.initZ();
+            PrivateBin.Controller.initZlib();
             global.atob = common.atob;
             global.btoa = common.btoa;
-            const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+            const cipherMessage = await PrivateBin.CryptTool.cipher(
                     'foo', 'bar', message, []
                 ),
-                plaintext = await $.PrivateBin.CryptTool.decipher(
+                plaintext = await PrivateBin.CryptTool.decipher(
                     'foo', 'bar', cipherMessage
                 );
             clean();
@@ -64,10 +70,10 @@ describe('CryptTool', function () {
             assert.strictEqual(message, plaintext);
         });
 
-        it('can en- and decrypt a particular message (#260)', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
+        it('can en- and decrypt a particular message (#260)', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
                 async function (key, password) {
                     const message = `
 1 subgoal
@@ -91,17 +97,21 @@ isWhile : interp (while expr sBody) (MemElem mem) =
 ======================== ( 1 / 1 )
 conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
 `;
-                    const clean = jsdom();
+                    const clean = globalThis.cleanup();
                     // ensure zlib is getting loaded
-                    $.PrivateBin.Controller.initZ();
+                    PrivateBin.Controller.initZlib();
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
-                    const cipherMessage = await $.PrivateBin.CryptTool.cipher(
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
+                    const cipherMessage = await PrivateBin.CryptTool.cipher(
                             key, password, message, []
                         ),
-                        plaintext = await $.PrivateBin.CryptTool.decipher(
+                        plaintext = await PrivateBin.CryptTool.decipher(
                                 key, password, cipherMessage
                         );
                     clean();
@@ -110,7 +120,7 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
                     return result;
                 }
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
     });
 
@@ -119,23 +129,25 @@ conseq_or_bottom inv (interp (nth_iterate sBody n) (MemElem mem))
         let keys = [];
 
         // the parameter is used to ensure the test is run more then one time
-        it('returns random, non-empty keys', function () {
-            jsc.assert(jsc.forall(
-                'integer',
-                function(counter) {
-                    const clean = jsdom();
+        it('returns random, non-empty keys', () => {
+            fc.assert(fc.property(
+                fc.integer(),
+                function() {
+                    const clean = globalThis.cleanup();
                     Object.defineProperty(window, 'crypto', {
                         value: new WebCrypto(),
-                        writeable: false
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
                     });
-                    const key = $.PrivateBin.CryptTool.getSymmetricKey(),
+                    const key = PrivateBin.CryptTool.getSymmetricKey(),
                         result = (key !== '' && keys.indexOf(key) === -1);
                     keys.push(key);
                     clean();
                     return result;
                 }
             ),
-            {tests: 10});
+            {numRuns: 10});
         });
     });
 });

+ 118 - 97
js/test/DiscussionViewer.js

@@ -1,112 +1,133 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('DiscussionViewer', function () {
     describe('handleNotification, prepareNewDiscussion, addComment, finishDiscussion, getReplyMessage, getReplyNickname, getReplyCommentId & highlightComment', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'displays & hides comments as requested',
-            jsc.array(
-                jsc.record({
-                    idArray: jsc.nearray(common.jscAlnumString()),
-                    parentidArray: jsc.nearray(common.jscAlnumString()),
-                    data: jsc.string,
-                    meta: jsc.record({
-                        nickname: jsc.string,
-                        postdate: jsc.nat,
-                        vizhash: jsc.string
+        it('displays & hides comments as requested', () => {
+            fc.assert(fc.property(
+                fc.array(
+                    fc.record({
+                        idArray: fc.array(common.fcAlnumString(), {minLength: 1}),
+                        parentidArray: fc.array(common.fcAlnumString(), {minLength: 1}),
+                        data: fc.string(),
+                        meta: fc.record({
+                            nickname: fc.string(),
+                            postdate: fc.nat(),
+                            vizhash: fc.string()
+                        })
                     })
-                })
-            ),
-            'nat',
-            'bool',
-            'string',
-            'string',
-            jsc.elements(['loading', 'danger', 'other']),
-            'nestring',
-            function (comments, commentKey, fadeOut, nickname, message, alertType, alert) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<div id="discussion"><h4>Discussion</h4>' +
-                    '<div id="commentcontainer"></div></div><div id="templates">' +
-                    '<article id="commenttemplate" class="comment">' +
-                    '<div class="commentmeta"><span class="nickname">name</span>' +
-                    '<span class="commentdate">0000-00-00</span></div>' +
-                    '<div class="commentdata">c</div>' +
-                    '<button class="btn btn-default btn-sm">Reply</button>' +
-                    '</article><p id="commenttailtemplate" class="comment">' +
-                    '<button class="btn btn-default btn-sm">Add comment</button>' +
-                    '</p><div id="replytemplate" class="reply hidden">' +
-                    '<input type="text" id="nickname" class="form-control" ' +
-                    'title="Optional nickname…" placeholder="Optional ' +
-                    'nickname…" /><textarea id="replymessage" ' +
-                    'class="replymessage form-control" cols="80" rows="7">' +
-                    '</textarea><br /><div id="replystatus" role="alert" ' +
-                    'class="statusmessage hidden alert"><span class="glyphicon" ' +
-                    'aria-hidden="true"></span> </div><button id="replybutton" ' +
-                    'class="btn btn-default btn-sm">Post comment</button></div></div>'
-                );
-                $.PrivateBin.Model.init();
-                $.PrivateBin.DiscussionViewer.init();
-                results.push(
-                    !$('#discussion').hasClass('hidden')
-                );
-                $.PrivateBin.DiscussionViewer.prepareNewDiscussion();
-                results.push(
-                    $('#discussion').hasClass('hidden')
-                );
-                comments.forEach(function (comment) {
-                    comment.id = comment.idArray.join('');
-                    comment.parentid = comment.parentidArray.join('');
-                    $.PrivateBin.DiscussionViewer.addComment($.PrivateBin.Helper.CommentFactory(comment), comment.data, comment.meta.nickname);
-                });
-                results.push(
-                    $('#discussion').hasClass('hidden')
-                );
-                $.PrivateBin.DiscussionViewer.finishDiscussion();
-                results.push(
-                    !$('#discussion').hasClass('hidden') &&
-                    comments.length + 1 >= $('#commentcontainer').children().length
-                );
-                if (comments.length > 0) {
-                    if (commentKey >= comments.length) {
-                        commentKey = commentKey % comments.length;
+                ),
+                fc.nat(),
+                fc.boolean(),
+                fc.string(),
+                fc.string(),
+                fc.constantFrom('loading', 'danger', 'other'),
+                fc.string({minLength: 1}),
+                function (comments, commentKey, fadeOut, nickname, message, alertType, alert) {
+                    var clean = globalThis.cleanup(),
+                        results = [];
+                    PrivateBin.Model.reset();
+                    PrivateBin.Helper.reset();
+                    document.body.innerHTML = (
+                        '<div id="status" class="hidden"></div>' +
+                        '<div id="errormessage" class="hidden"></div>' +
+                        `<div id="discussion">
+	<h4>Discussion</h4>
+	<div id="commentcontainer"></div>
+</div>
+<div id="templates">
+	<article id="commenttemplate" class="comment">
+		<div class="commentmeta">
+			<span class="nickname">name</span>
+			<span class="commentdate">0000-00-00</span>
+		</div>
+		<div class="commentdata">c</div>
+		<button class="btn btn-default btn-sm">Reply</button>
+	</article>
+	<p id="commenttailtemplate" class="comment">
+		<button class="btn btn-default btn-sm">Add comment</button>
+	</p>
+	<div id="replytemplate" class="reply hidden">
+		<input type="text" id="nickname" class="form-control" title="Optional nickname…"
+			placeholder="Optional nickname…" />
+		<textarea id="replymessage" class="replymessage form-control" cols="80" rows="7"></textarea>
+		<br />
+		<div id="replystatus" role="alert" class="statusmessage hidden alert">
+			<span class="glyphicon" aria-hidden="true"></span>
+		</div>
+		<button id="replybutton" class="btn btn-default btn-sm">Post comment</button>
+	</div>
+</div>
+`
+                    );
+                    PrivateBin.Model.init();
+                    PrivateBin.Alert.init();
+                    PrivateBin.DiscussionViewer.init();
+                    results.push(
+                        !document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    PrivateBin.DiscussionViewer.prepareNewDiscussion();
+                    results.push(
+                        document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    comments.forEach(function (comment) {
+                        comment.id = comment.idArray.join('');
+                        comment.parentid = comment.parentidArray.join('');
+                        comment.getCreated = function() { return comment.meta.postdate; };
+                        comment.getIcon = function() { return comment.meta.vizhash; };
+                        PrivateBin.DiscussionViewer.addComment(comment, comment.data, comment.meta.nickname);
+                    });
+                    results.push(
+                        document.getElementById('discussion').classList.contains('hidden')
+                    );
+                    PrivateBin.DiscussionViewer.finishDiscussion();
+                    results.push(
+                        !document.getElementById('discussion').classList.contains('hidden') &&
+                        comments.length + 1 >= document.getElementById('commentcontainer').children.length
+                    );
+                    if (comments.length > 0) {
+                        if (commentKey >= comments.length) {
+                            commentKey = commentKey % comments.length;
+                        }
+                        PrivateBin.DiscussionViewer.highlightComment(comments[commentKey].id, fadeOut);
+                        results.push(
+                            document.getElementById('comment_' + comments[commentKey].id).classList.contains('highlight')
+                        );
                     }
-                    $.PrivateBin.DiscussionViewer.highlightComment(comments[commentKey].id, fadeOut);
+                    // clicking "Add comment" button should open the reply form
+                    document.getElementById('commentcontainer').querySelector('button').click();
                     results.push(
-                        $('#comment_' + comments[commentKey].id).hasClass('highlight')
+                        document.getElementById('reply') !== null &&
+                        !document.getElementById('reply').classList.contains('hidden')
                     );
-                }
-                $('#commentcontainer').find('button')[0].click();
-                results.push(
-                    !$('#reply').hasClass('hidden')
-                );
-                $('#reply #nickname').val(nickname);
-                $('#reply #replymessage').val(message);
-                $.PrivateBin.DiscussionViewer.getReplyCommentId();
-                results.push(
-                    $.PrivateBin.DiscussionViewer.getReplyNickname() === $('#reply #nickname').val() &&
-                    $.PrivateBin.DiscussionViewer.getReplyMessage() === $('#reply #replymessage').val()
-                );
-                var notificationResult = $.PrivateBin.DiscussionViewer.handleNotification(alertType === 'other' ? alert : alertType);
-                if (alertType === 'loading') {
-                    results.push(notificationResult === false);
-                } else {
+                    document.querySelector('#reply #nickname').value = nickname;
+                    document.querySelector('#reply #replymessage').value = message;
+                    PrivateBin.DiscussionViewer.getReplyCommentId();
                     results.push(
-                        alertType === 'danger' ? (
-                            notificationResult.hasClass('alert-danger') &&
-                            !notificationResult.hasClass('alert-info')
-                        ) : (
-                            !notificationResult.hasClass('alert-danger') &&
-                            notificationResult.hasClass('alert-info')
-                        )
+                        PrivateBin.DiscussionViewer.getReplyNickname() === document.querySelector('#reply #nickname').value &&
+                        PrivateBin.DiscussionViewer.getReplyMessage() === document.querySelector('#reply #replymessage').value
                     );
+                    var notificationResult = PrivateBin.DiscussionViewer.handleNotification(alertType === 'other' ? alert : alertType);
+                    if (alertType === 'loading') {
+                        results.push(notificationResult === false);
+                    } else {
+                        results.push(
+                            alertType === 'danger' ? (
+                                notificationResult.classList.contains('alert-danger') &&
+                                !notificationResult.classList.contains('alert-info')
+                            ) : (
+                                !notificationResult.classList.contains('alert-danger') &&
+                                notificationResult.classList.contains('alert-info')
+                            )
+                        );
+                    }
+                    clean();
+                    return results.every(element => element);
                 }
-                clean();
-                return results.every(element => element);
-            }
-        );
+            ));
+        });
     });
 });

+ 70 - 61
js/test/Editor.js

@@ -1,70 +1,79 @@
 'use strict';
 require('../common');
+const fc = require('fast-check');
 
 describe('Editor', function () {
     describe('show, hide, getText, setText & isPreview', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'returns text fed into the textarea, handles editor tabs',
-            'string',
-            function (text) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<ul id="editorTabs" class="nav nav-tabs hidden"><li ' +
-                    'role="presentation" class="active"><a id="messageedit" ' +
-                    'href="#">Editor</a></li><li role="presentation"><a ' +
-                    'id="messagepreview" href="#">Preview</a></li></ul><div ' +
-                    'id="placeholder" class="hidden">+++ no document text +++</div>' +
-                    '<div id="prettymessage" class="hidden"><pre id="prettyprint" ' +
-                    'class="prettyprint linenums:1"></pre></div><div ' +
-                    'id="plaintext" class="hidden"></div><p><textarea ' +
-                    'id="message" name="message" cols="80" rows="25" ' +
-                    'class="form-control hidden"></textarea></p>'
-                );
-                $.PrivateBin.Editor.init();
-                results.push(
-                    $('#editorTabs').hasClass('hidden') &&
-                    $('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.show();
-                results.push(
-                    !$('#editorTabs').hasClass('hidden') &&
-                    !$('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.hide();
-                results.push(
-                    $('#editorTabs').hasClass('hidden') &&
-                    $('#message').hasClass('hidden')
-                );
-                $.PrivateBin.Editor.show();
-                $.PrivateBin.Editor.focusInput();
-                results.push(
-                    $.PrivateBin.Editor.getText().length === 0
-                );
-                $.PrivateBin.Editor.setText(text);
-                results.push(
-                    $.PrivateBin.Editor.getText() === $('#message').val()
-                );
-                $.PrivateBin.Editor.setText();
-                results.push(
-                    !$.PrivateBin.Editor.isPreview() &&
-                    !$('#message').hasClass('hidden')
-                );
-                $('#messagepreview').trigger('click');
-                results.push(
-                    $.PrivateBin.Editor.isPreview() &&
-                    $('#message').hasClass('hidden')
-                );
-                $('#messageedit').trigger('click');
-                results.push(
-                    !$.PrivateBin.Editor.isPreview() &&
-                    !$('#message').hasClass('hidden')
-                );
-                clean();
-                return results.every(element => element);
-            }
-        );
+        it('returns text fed into the textarea, handles editor tabs', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (text) {
+                    var clean = globalThis.cleanup(),
+                        results = [];
+                    document.body.innerHTML = (
+                        '<ul id="editorTabs" class="nav nav-tabs hidden">' +
+                            '<li role="presentation" class="active">' +
+                                '<a id="messageedit" href="#">Editor</a>' +
+                            '</li>' +
+                            '<li role="presentation">' +
+                                '<a id="messagepreview" href="#">Preview</a>' +
+                            '</li>' +
+                        '</ul>' +
+                        '<div id="placeholder" class="hidden">+++ no document text +++</div>' +
+                        '<div id="prettymessage" class="hidden">' +
+                            '<pre id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div>' +
+                        '<div id="plaintext" class="hidden"></div>' +
+                        '<p>' +
+                            '<textarea id="message" name="message" cols="80" rows="25" class="form-control hidden"></textarea>' +
+                        '</p>' +
+                        '<input id="messagetab" type="checkbox" checked="checked" />'
+                    );
+                    PrivateBin.Editor.init();
+                    results.push(
+                        document.getElementById('editorTabs').classList.contains('hidden') &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.show();
+                    results.push(
+                        !document.getElementById('editorTabs').classList.contains('hidden') &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.hide();
+                    results.push(
+                        document.getElementById('editorTabs').classList.contains('hidden') &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    PrivateBin.Editor.show();
+                    PrivateBin.Editor.focusInput();
+                    results.push(
+                        PrivateBin.Editor.getText().length === 0
+                    );
+                    PrivateBin.Editor.setText(text);
+                    results.push(
+                        PrivateBin.Editor.getText() === document.getElementById('message').value
+                    );
+                    PrivateBin.Editor.setText();
+                    results.push(
+                        !PrivateBin.Editor.isPreview() &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    document.getElementById('messagepreview').click();
+                    results.push(
+                        PrivateBin.Editor.isPreview() &&
+                        document.getElementById('message').classList.contains('hidden')
+                    );
+                    document.getElementById('messageedit').click();
+                    results.push(
+                        !PrivateBin.Editor.isPreview() &&
+                        !document.getElementById('message').classList.contains('hidden')
+                    );
+                    clean();
+                    return results.every(element => element);
+                }
+            ));
+        });
     });
 });

+ 316 - 268
js/test/Helper.js

@@ -1,45 +1,68 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('Helper', function () {
     describe('secondsToHuman', function () {
-        jsc.property('returns an array with a number and a word', 'integer', function (number) {
-            var result = $.PrivateBin.Helper.secondsToHuman(number);
-            return Array.isArray(result) &&
-                result.length === 2 &&
-                result[0] === parseInt(result[0], 10) &&
-                typeof result[1] === 'string';
+        it('returns an array with a number and a word', () => {
+            fc.assert(fc.property(fc.integer(), function (number) {
+                var result = PrivateBin.Helper.secondsToHuman(number);
+                return Array.isArray(result) &&
+                    result.length === 2 &&
+                    result[0] === parseInt(result[0], 10) &&
+                    typeof result[1] === 'string';
+            }));
         });
-        jsc.property('returns seconds on the first array position', 'integer 59', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === number;
+        it('returns seconds on the first array position', () => {
+            fc.assert(fc.property(fc.integer({max: 59}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === number;
+            }));
         });
-        jsc.property('returns seconds on the second array position', 'integer 59', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'second';
+        it('returns seconds on the second array position', () => {
+            fc.assert(fc.property(fc.integer({max: 59}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'second';
+            }));
         });
-        jsc.property('returns minutes on the first array position', 'integer 60 3599', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60);
+        it('returns minutes on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 60, max: 3599}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / 60);
+            }));
         });
-        jsc.property('returns minutes on the second array position', 'integer 60 3599', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'minute';
+        it('returns minutes on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 60, max: 3599}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'minute';
+            }));
         });
-        jsc.property('returns hours on the first array position', 'integer 3600 86399', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
+        it('returns hours on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 3600, max: 86399}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60));
+            }));
         });
-        jsc.property('returns hours on the second array position', 'integer 3600 86399', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'hour';
+        it('returns hours on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 3600, max: 86399}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'hour';
+            }));
         });
-        jsc.property('returns days on the first array position', 'integer 86400 5184000', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
+        it('returns days on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 86400, max: 5184000}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24));
+            }));
         });
-        jsc.property('returns days on the second array position', 'integer 86400 5184000', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'day';
+        it('returns days on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 86400, max: 5184000}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'day';
+            }));
         });
         // max safe integer as per http://ecma262-5.com/ELS5_HTML.htm#Section_8.5
-        jsc.property('returns months on the first array position', 'integer 5184000 9007199254740991', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
+        it('returns months on the first array position', () => {
+            fc.assert(fc.property(fc.integer({min: 5184001}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[0] === Math.floor(number / (60 * 60 * 24 * 30));
+            }));
         });
-        jsc.property('returns months on the second array position', 'integer 5184000 9007199254740991', function (number) {
-            return $.PrivateBin.Helper.secondsToHuman(number)[1] === 'month';
+        it('returns months on the second array position', () => {
+            fc.assert(fc.property(fc.integer({min: 5184001}), function (number) {
+                return PrivateBin.Helper.secondsToHuman(number)[1] === 'month';
+            }));
         });
     });
 
@@ -47,307 +70,332 @@ describe('Helper', function () {
     // TODO: This needs to be tested using a browser.
     describe('selectText', function () {
         this.timeout(30000);
-        jsc.property(
-            'selection contains content of given ID',
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            'nearray string',
-            function (ids, contents) {
-                var html = '',
-                    result = true,
-                    clean = jsdom(html);
-                ids.forEach(function(item, i) {
-                    html += '<div id="' + item.join('') + '">' + $.PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
-                });
-                // TODO: As per https://github.com/tmpvar/jsdom/issues/321 there is no getSelection in jsdom, yet.
-                // Once there is one, uncomment the block below to actually check the result.
-                /*
-                ids.forEach(function(item, i) {
-                    $.PrivateBin.Helper.selectText(item.join(''));
-                    result *= (contents[i] || contents[0]) === window.getSelection().toString();
-                });
-                */
-                clean();
-                return Boolean(result);
-            }
-        );
+        it('selection contains content of given ID', () => {
+            fc.assert(fc.property(
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                fc.array(fc.string(), {minLength: 1}),
+                function (ids, contents) {
+                    var html = '',
+                        result = true,
+                        clean = globalThis.cleanup(html);
+                    ids.forEach(function(item, i) {
+                        html += '<div id="' + item.join('') + '">' + PrivateBin.Helper.htmlEntities(contents[i] || contents[0]) + '</div>';
+                    });
+                    // TODO: As per https://github.com/tmpvar/jsdom/issues/321 there is no getSelection in jsdom, yet.
+                    // Once there is one, uncomment the block below to actually check the result.
+                    /*
+                    ids.forEach(function(item, i) {
+                        PrivateBin.Helper.selectText(item.join(''));
+                        result *= (contents[i] || contents[0]) === window.getSelection().toString();
+                    });
+                    */
+                    clean();
+                    return Boolean(result);
+                }
+            ));
+        });
     });
 
     describe('urls2links', function () {
+        function getTextAsRenderedHtml(stringContent) {
+            const tempDiv = document.createElement('div');
+            tempDiv.textContent = stringContent;
+            return tempDiv.innerHTML;
+        }
+
         this.timeout(30000);
         before(function () {
-            cleanup = jsdom();
+            globalThis.cleanup();
         });
 
-        jsc.property(
-            'ignores non-URL content',
-            'string',
-            function (content) {
-                // eslint-disable-next-line no-control-regex
-                content = content.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                let clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
-                e.text(content);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.text();
-                clean();
-                return content === result;
-            }
-        );
-        jsc.property(
-            'replaces URLs with anchors',
-            'string',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            'string',
-            function (prefix, url, fragment, postfix) {
-                // eslint-disable-next-line no-control-regex
-                prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                // eslint-disable-next-line no-control-regex
-                postfix  = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
-                url.fragment = fragment.join('');
-                let urlString = common.urlToString(url),
-                    clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
+        it('ignores non-URL content', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (content) {
+                    // eslint-disable-next-line no-control-regex
+                    content = content.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+                    e.textContent = content;
+                    PrivateBin.Helper.urls2links(e);
+                    let result = e.textContent;
+                    clean();
+                    return content === result;
+                }
+            ));
+        });
+        it('replaces URLs with anchors', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                fc.string(),
+                function (prefix, url, fragment, postfix) {
+                    // eslint-disable-next-line no-control-regex
+                    prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    // eslint-disable-next-line no-control-regex
+                    postfix  = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
+                    url.fragment = fragment.join('');
+                    let urlString = common.urlToString(url),
+                        clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+
+                    // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
+                    if (
+                        url.query[-1] === '&' &&
+                        (parseInt(url.fragment.charAt(0), 10) >= 0 || url.fragment.charAt(0) === 'x')
+                    ) {
+                        url.query.pop();
+                        urlString = common.urlToString(url);
+                        postfix = '';
+                    }
+                    e.textContent = prefix + urlString + postfix;
+                    PrivateBin.Helper.urls2links(e);
+
+                    let result = e.innerHTML;
+                    clean();
 
-                // special cases: When the query string and fragment imply the beginning of an HTML entity, eg. &#0 or &#x
-                if (
-                    url.query[-1] === '&' &&
-                    (parseInt(url.fragment.charAt(0), 10) >= 0 || url.fragment.charAt(0) === 'x')
-                ) {
-                    url.query.pop();
-                    urlString = common.urlToString(url);
-                    postfix = '';
+                    urlString = getTextAsRenderedHtml(urlString);
+                    const expected = getTextAsRenderedHtml(prefix) + '<a href="' + urlString + '" target="_blank" rel="nofollow noopener noreferrer">' + urlString + '</a>' + getTextAsRenderedHtml(postfix);
+                    const expectedElement = document.createElement('div');
+                    expectedElement.innerHTML = expected;
+                    return expectedElement.innerHTML === result;
+                }
+            ));
+        });
+        it('replaces magnet links with anchors', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(common.fcQueryString()),
+                fc.string(),
+                function (prefix, query, postfix) {
+                    // eslint-disable-next-line no-control-regex
+                    prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
+                    // eslint-disable-next-line no-control-regex
+                    postfix = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
+                    let url  = 'magnet:?' + query.join('').replace(/^&+|&+$/gm, ''),
+                        clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="foo"></div>';
+                    let e = document.getElementById('foo');
+                    e.textContent = prefix + url + postfix;
+                    PrivateBin.Helper.urls2links(e);
+                    let result = e.innerHTML;
+                    clean();
+                    url = getTextAsRenderedHtml(url);
+                    const expected = getTextAsRenderedHtml(prefix) + '<a href="' + url + '" target="_blank" rel="nofollow noopener noreferrer">' + url + '</a>' + getTextAsRenderedHtml(postfix);
+                    const expectedElement = document.createElement('div');
+                    expectedElement.innerHTML = expected;
+                    return expectedElement.innerHTML === result;
                 }
-                e.text(prefix + urlString + postfix);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.html();
-                clean();
-                urlString = $('<div />').text(urlString).html();
-                const expected = $('<div />').text(prefix).html() + '<a href="' + urlString + '" target="_blank" rel="nofollow noopener noreferrer">' + urlString + '</a>' + $('<div />').text(postfix).html();
-                return expected === result;
-            }
-        );
-        jsc.property(
-            'replaces magnet links with anchors',
-            'string',
-            jsc.array(common.jscQueryString()),
-            'string',
-            function (prefix, query, postfix) {
-                // eslint-disable-next-line no-control-regex
-                prefix = prefix.replace(/\r|\f/g, '\n').replace(/\u0000|\u000b/g, '');
-                // eslint-disable-next-line no-control-regex
-                postfix = ' ' + postfix.replace(/\r/g, '\n').replace(/\u0000/g, '');
-                let url  = 'magnet:?' + query.join('').replace(/^&+|&+$/gm, ''),
-                    clean = jsdom();
-                $('body').html('<div id="foo"></div>');
-                let e = $('#foo');
-                e.text(prefix + url + postfix);
-                $.PrivateBin.Helper.urls2links(e);
-                let result = e.html();
-                clean();
-                url = $('<div />').text(url).html();
-                return $('<div />').text(prefix).html() + '<a href="' + url + '" target="_blank" rel="nofollow noopener noreferrer">' + url + '</a>' + $('<div />').text(postfix).html() === result;
-            }
-        );
+            ));
+        });
     });
 
     describe('sprintf', function () {
-        jsc.property(
-            'replaces %s in strings with first given parameter',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                var result = prefix + params[0] + postfix;
-                params.unshift(prefix + '%s' + postfix);
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
-        jsc.property(
-            'replaces %d in strings with first given parameter',
-            'string',
-            '(small nearray) nat',
-            'string',
-            function (prefix, params, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
-                var result = prefix + params[0] + postfix;
-                params.unshift(prefix + '%d' + postfix);
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
-        jsc.property(
-            'replaces %d in strings with 0 if first parameter is not a number',
-            'string',
-            '(small nearray) falsy',
-            'string',
-            function (prefix, params, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '%%');
-                postfix = postfix.replace(/%(s|d)/g, '%%');
-                var result = prefix + '0' + postfix;
-                params.unshift(prefix + '%d' + postfix);
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
-        jsc.property(
-            'replaces %d and %s in strings in order',
-            'string',
-            'nat',
-            'string',
-            'string',
-            'string',
-            function (prefix, uint, middle, string, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '');
-                middle  =  middle.replace(/%(s|d)/g, '');
-                postfix = postfix.replace(/%(s|d)/g, '');
-                var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
-                    result = prefix + uint + middle + string + postfix;
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
-        jsc.property(
-            'replaces %d and %s in strings in reverse order',
-            'string',
-            'nat',
-            'string',
-            'string',
-            'string',
-            function (prefix, uint, middle, string, postfix) {
-                prefix  =  prefix.replace(/%(s|d)/g, '');
-                middle  =  middle.replace(/%(s|d)/g, '');
-                postfix = postfix.replace(/%(s|d)/g, '');
-                var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
-                    result = prefix + string + middle + uint + postfix;
-                return result === $.PrivateBin.Helper.sprintf.apply(this, params);
-            }
-        );
+        it('replaces %s in strings with first given parameter', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + params[0] + postfix;
+                    params.unshift(prefix + '%s' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d in strings with first given parameter', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.nat(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + params[0] + postfix;
+                    params.unshift(prefix + '%d' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d in strings with 0 if first parameter is not a number', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.falsy(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '%%');
+                    postfix = postfix.replace(/%(s|d)/g, '%%');
+                    var result = prefix + '0' + postfix;
+                    params.unshift(prefix + '%d' + postfix);
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d and %s in strings in order', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.nat(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                function (prefix, uint, middle, string, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '');
+                    middle  =  middle.replace(/%(s|d)/g, '');
+                    postfix = postfix.replace(/%(s|d)/g, '');
+                    var params = [prefix + '%d' + middle + '%s' + postfix, uint, string],
+                        result = prefix + uint + middle + string + postfix;
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
+        it('replaces %d and %s in strings in reverse order', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.nat(),
+                fc.string(),
+                fc.string(),
+                fc.string(),
+                function (prefix, uint, middle, string, postfix) {
+                    prefix  =  prefix.replace(/%(s|d)/g, '');
+                    middle  =  middle.replace(/%(s|d)/g, '');
+                    postfix = postfix.replace(/%(s|d)/g, '');
+                    var params = [prefix + '%s' + middle + '%d' + postfix, string, uint],
+                        result = prefix + string + middle + uint + postfix;
+                    return result === PrivateBin.Helper.sprintf.apply(this, params);
+                }
+            ));
+        });
     });
 
     describe('getCookie', function () {
         this.timeout(30000);
         before(function () {
-            cleanup();
+            globalThis.cleanup();
         });
 
 /* TODO test fails since jsDOM version 17 - document.cookie remains empty
-        jsc.property(
-            'returns the requested cookie',
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            jsc.nearray(jsc.nearray(common.jscAlnumString())),
-            function (labels, values) {
-                let selectedKey = '', selectedValue = '';
-                const clean = jsdom();
-                labels.forEach(function(item, i) {
-                    const key = item.join(''),
-                        value = (values[i] || values[0]).join('');
-                    document.cookie = key + '=' + value;
-                    if (Math.random() < 1 / i || selectedKey === key)
-                    {
-                        selectedKey = key;
-                        selectedValue = value;
-                    }
-                });
-                const result = $.PrivateBin.Helper.getCookie(selectedKey);
-                $.PrivateBin.Helper.reset();
-                clean();
-                return result === selectedValue;
-            }
-        ); */
+        it('returns the requested cookie', () => {
+            fc.assert(fc.property(
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                fc.array(fc.array(common.fcAlnumString(), {minLength: 1}), {minLength: 1}),
+                function (labels, values) {
+                    let selectedKey = '', selectedValue = '';
+                    const clean = globalThis.cleanup();
+                    labels.forEach(function(item, i) {
+                        const key = item.join(''),
+                            value = (values[i] || values[0]).join('');
+                        document.cookie = key + '=' + value;
+                        if (Math.random() < 1 / i || selectedKey === key)
+                        {
+                            selectedKey = key;
+                            selectedValue = value;
+                        }
+                    });
+                    const result = PrivateBin.Helper.getCookie(selectedKey);
+                    PrivateBin.Helper.reset();
+                    clean();
+                    return result === selectedValue;
+                }
+            ));
+        }); */
     });
 
     describe('baseUri', function () {
         this.timeout(30000);
-        jsc.property(
-            'returns the URL without query & fragment',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            function (schema, url) {
-                url.schema = schema;
-                const fullUrl = common.urlToString(url);
-                delete(url.query);
-                delete(url.fragment);
-                $.PrivateBin.Helper.reset();
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: fullUrl}),
-                    result = $.PrivateBin.Helper.baseUri();
-                clean();
-                return expected === result;
-            }
-        );
+        it('returns the URL without query & fragment', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                function (schema, url) {
+                    url.schema = schema;
+                    const fullUrl = common.urlToString(url);
+                    delete(url.query);
+                    delete(url.fragment);
+                    PrivateBin.Helper.reset();
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: fullUrl}),
+                        result = PrivateBin.Helper.baseUri();
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('htmlEntities', function () {
         before(function () {
-            cleanup = jsdom();
+            globalThis.cleanup();
         });
 
-        jsc.property(
-            'removes all HTML entities from any given string',
-            'string',
-            function (string) {
-                var result = $.PrivateBin.Helper.htmlEntities(string);
-                return !(/[<>]/.test(result)) && !(string.indexOf('&') > -1 && !(/&amp;/.test(result)));
-            }
-        );
+        it('removes all HTML entities from any given string', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (string) {
+                    var result = PrivateBin.Helper.htmlEntities(string);
+                    return !(/[<>]/.test(result)) && !(string.indexOf('&') > -1 && !(/&amp;/.test(result)));
+                }
+            ));
+        });
     });
 
     describe('formatBytes', function () {
-        jsc.property('returns 0 B for 0 bytes', function () {
-            return $.PrivateBin.Helper.formatBytes(0) === '0 B';
+        it('returns 0 B for 0 bytes', function () {
+            return PrivateBin.Helper.formatBytes(0) === '0 B';
         });
 
-        jsc.property('formats bytes < 1000 as B', function () {
-            return $.PrivateBin.Helper.formatBytes(500) === '500 B';
+        it('formats bytes < 1000 as B', function () {
+            return PrivateBin.Helper.formatBytes(500) === '500 B';
         });
 
-        jsc.property('formats kilobytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1500) === '1.5 kB';
+        it('formats kilobytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1500) === '1.5 kB';
         });
 
-        jsc.property('formats megabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(2 * 1000 * 1000) === '2 MB';
+        it('formats megabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(2 * 1000 * 1000) === '2 MB';
         });
 
-        jsc.property('formats gigabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(3.45 * 1000 * 1000 * 1000) === '3.45 GB';
+        it('formats gigabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(3.45 * 1000 * 1000 * 1000) === '3.45 GB';
         });
 
-        jsc.property('formats terabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.75 * 1000 ** 4) === '1.75 TB';
+        it('formats terabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.75 * 1000 ** 4) === '1.75 TB';
         });
 
-        jsc.property('formats petabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.5 * 1000 ** 5) === '1.5 PB';
+        it('formats petabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.5 * 1000 ** 5) === '1.5 PB';
         });
 
-        jsc.property('formats exabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.2345 * 1000 ** 6).startsWith('1.23 EB');
+        it('formats exabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.2345 * 1000 ** 6).startsWith('1.23 EB');
         });
 
-        jsc.property('formats yottabytes correctly', function () {
-            return $.PrivateBin.Helper.formatBytes(1.23 * 1000 ** 8).startsWith('1.23 YB');
+        it('formats yottabytes correctly', function () {
+            return PrivateBin.Helper.formatBytes(1.23 * 1000 ** 8).startsWith('1.23 YB');
         });
 
-        jsc.property('rounds to two decimal places', function () {
-            return $.PrivateBin.Helper.formatBytes(1234567) === '1.23 MB';
+        it('rounds to two decimal places', function () {
+            return PrivateBin.Helper.formatBytes(1234567) === '1.23 MB';
         });
     });
 
 
     describe('isBootstrap5', function () {
-        jsc.property('Bootstrap 5 has been detected', function () {
+        it('Bootstrap 5 has been detected', function () {
             global.bootstrap = {};
-            return $.PrivateBin.Helper.isBootstrap5() === true;
+            return PrivateBin.Helper.isBootstrap5() === true;
         });
 
-        jsc.property('Bootstrap 5 has not been detected', function () {
+        it('Bootstrap 5 has not been detected', function () {
             delete global.bootstrap;
-            return $.PrivateBin.Helper.isBootstrap5() === false;
+            return PrivateBin.Helper.isBootstrap5() === false;
         });
     });
 });

+ 210 - 199
js/test/I18n.js

@@ -1,177 +1,188 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('I18n', function () {
     describe('translate', function () {
         this.timeout(30000);
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
 
-        jsc.property(
-            'returns message ID unchanged if no translation found',
-            'string',
-            function (messageId) {
-                messageId   = messageId.replace(/%(s|d)/g, '%%');
-                var plurals = [messageId, messageId + 's'],
-                    fake    = [messageId],
-                    result  = $.PrivateBin.I18n.translate(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var alias = $.PrivateBin.I18n._(messageId);
-                $.PrivateBin.I18n.reset();
-
-                var pluralResult = $.PrivateBin.I18n.translate(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var pluralAlias = $.PrivateBin.I18n._(plurals);
-                $.PrivateBin.I18n.reset();
-
-                var fakeResult = $.PrivateBin.I18n.translate(fake);
-                $.PrivateBin.I18n.reset();
-
-                var fakeAlias = $.PrivateBin.I18n._(fake);
-                $.PrivateBin.I18n.reset();
-
-                if (messageId.indexOf('<a') === -1) {
-                    messageId = $.PrivateBin.Helper.htmlEntities(messageId);
-                } else {
-                    messageId = DOMPurify.sanitize(
-                        messageId, {
-                            ALLOWED_TAGS: ['a', 'i', 'span'],
+        it('returns message ID unchanged if no translation found', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (messageId) {
+                    messageId   = messageId.replace(/%(s|d)/g, '%%');
+                    var plurals = [messageId, messageId + 's'],
+                        fake    = [messageId],
+                        result  = PrivateBin.I18n.translate(messageId);
+                    PrivateBin.I18n.reset();
+
+                    var alias = PrivateBin.I18n._(messageId);
+                    PrivateBin.I18n.reset();
+
+                    var pluralResult = PrivateBin.I18n.translate(plurals);
+                    PrivateBin.I18n.reset();
+
+                    var pluralAlias = PrivateBin.I18n._(plurals);
+                    PrivateBin.I18n.reset();
+
+                    var fakeResult = PrivateBin.I18n.translate(fake);
+                    PrivateBin.I18n.reset();
+
+                    var fakeAlias = PrivateBin.I18n._(fake);
+                    PrivateBin.I18n.reset();
+
+                    if (messageId.indexOf('<a') === -1) {
+                        messageId = PrivateBin.Helper.htmlEntities(messageId);
+                    } else {
+                        messageId = DOMPurify.sanitize(
+                            messageId, {
+                                ALLOWED_TAGS: ['a', 'i', 'span'],
+                                ALLOWED_ATTR: ['href', 'id']
+                            }
+                        );
+                    }
+                    return messageId === result && messageId === alias &&
+                        messageId === pluralResult && messageId === pluralAlias &&
+                        messageId === fakeResult && messageId === fakeAlias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter, encoding all, when no link is in the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    const translation = PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
+                    params.unshift(prefix + '%s' + postfix);
+                    const result = PrivateBin.I18n.translate.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    const alias = PrivateBin.I18n._.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%');
+                    const translation = DOMPurify.sanitize(
+                        prefix + '<a href="' + params[0] + '"></a>' + postfix, {
+                            ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
+                            ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
                             ALLOWED_ATTR: ['href', 'id']
                         }
                     );
+                    params.unshift(prefix + '<a href="%s"></a>' + postfix);
+                    const result = PrivateBin.I18n.translate.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    const alias = PrivateBin.I18n._.apply(this, params);
+                    PrivateBin.I18n.reset();
+                    return translation === result && translation === alias;
                 }
-                return messageId === result && messageId === alias &&
-                    messageId === pluralResult && messageId === pluralAlias &&
-                    messageId === fakeResult && messageId === fakeAlias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter, encoding all, when no link is in the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                const translation = $.PrivateBin.Helper.htmlEntities(prefix + params[0] + postfix);
-                params.unshift(prefix + '%s' + postfix);
-                const result = $.PrivateBin.I18n.translate.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                const alias = $.PrivateBin.I18n._.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter, encoding params only, when a link is part of the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%');
-                const translation = DOMPurify.sanitize(
-                    prefix + '<a href="' + params[0] + '"></a>' + postfix, {
-                        ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
-                        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
-                        ALLOWED_ATTR: ['href', 'id']
-                    }
-                );
-                params.unshift(prefix + '<a href="%s"></a>' + postfix);
-                const result = $.PrivateBin.I18n.translate.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                const alias = $.PrivateBin.I18n._.apply(this, params);
-                $.PrivateBin.I18n.reset();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                params[0] = params[0].replace(/%(s|d)/g, '%%');
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
-                const translation = $('<textarea>').text((prefix + params[0] + postfix)).text();
-                let args = Array.prototype.slice.call(params);
-                args.unshift(prefix + '%s' + postfix);
-                let clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args.unshift($('#i18n'));
-                $.PrivateBin.I18n.translate.apply(this, args);
-                const result = $('#i18n').text();
-                $.PrivateBin.I18n.reset();
-                clean();
-                clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args[0] = $('#i18n');
-                $.PrivateBin.I18n._.apply(this, args);
-                const alias = $('#i18n').text();
-                $.PrivateBin.I18n.reset();
-                clean();
-                return translation === result && translation === alias;
-            }
-        );
-        jsc.property(
-            'replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted',
-            'string',
-            '(small nearray) string',
-            'string',
-            function (prefix, params, postfix) {
-                prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
-                params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
-                postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
-                const translation = DOMPurify.sanitize(
-                    prefix + '<a href="' + params[0] + '"></a>' + postfix, {
-                        ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
-                        ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
-                        ALLOWED_ATTR: ['href', 'id']
-                    }
-                );
-                let args = Array.prototype.slice.call(params);
-                args.unshift(prefix + '<a href="%s"></a>' + postfix);
-                let clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args.unshift($('#i18n'));
-                $.PrivateBin.I18n.translate.apply(this, args);
-                const result = $('#i18n').html();
-                $.PrivateBin.I18n.reset();
-                clean();
-                clean = jsdom();
-                $('body').html('<div id="i18n"></div>');
-                args[0] = $('#i18n');
-                $.PrivateBin.I18n._.apply(this, args);
-                const alias = $('#i18n').html();
-                $.PrivateBin.I18n.reset();
-                clean();
-                return translation === result && translation === alias;
-            }
-        );
+            ));
+        });
+        it('replaces %s in strings with first given parameter into an element, encoding all, when no link is in the messageID', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    params[0] = params[0].replace(/%(s|d)/g, '%%');
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').replace(/<a/g, '');
+                    const tempDiv = document.createElement('textarea');
+                    tempDiv.textContent = (prefix + params[0] + postfix);
+                    const translation = tempDiv.textContent;
+                    let args = Array.prototype.slice.call(params);
+                    args.unshift(prefix + '%s' + postfix);
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    const i18nElement = document.getElementById('i18n');
+                    args.unshift(i18nElement);
+                    PrivateBin.I18n.translate.apply(this, args);
+                    const result = i18nElement.textContent;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    args[0] = document.getElementById('i18n');
+                    PrivateBin.I18n._.apply(this, args);
+                    const alias = document.getElementById('i18n').textContent;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
+        it('replaces %s in strings with first given parameter into an element, encoding params only, when a link is part of the messageID inserted', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                fc.array(fc.string(), {minLength: 1}),
+                fc.string(),
+                function (prefix, params, postfix) {
+                    prefix    =    prefix.replace(/%(s|d)/g, '%%').trim();
+                    params[0] = params[0].replace(/%(s|d)/g, '%%').trim();
+                    postfix   =   postfix.replace(/%(s|d)/g, '%%').trim();
+                    const translation = DOMPurify.sanitize(
+                        prefix + '<a href="' + params[0] + '"></a>' + postfix, {
+                            ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|magnet):)/i,
+                            ALLOWED_TAGS: ['a', 'i', 'span', 'kbd'],
+                            ALLOWED_ATTR: ['href', 'id']
+                        }
+                    );
+                    let args = Array.prototype.slice.call(params);
+                    args.unshift(prefix + '<a href="%s"></a>' + postfix);
+                    let clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    const i18nElement2 = document.getElementById('i18n');
+                    args.unshift(i18nElement2);
+                    PrivateBin.I18n.translate.apply(this, args);
+                    const result = i18nElement2.innerHTML;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    clean = globalThis.cleanup();
+                    document.body.innerHTML = '<div id="i18n"></div>';
+                    args[0] = document.getElementById('i18n');
+                    PrivateBin.I18n._.apply(this, args);
+                    const alias = document.getElementById('i18n').innerHTML;
+                    PrivateBin.I18n.reset();
+                    clean();
+                    return translation === result && translation === alias;
+                }
+            ));
+        });
     });
 
     describe('getPluralForm', function () {
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
 
-        jsc.property(
-            'returns valid key for plural form',
-            common.jscSupportedLanguages(),
-            'integer',
-            function(language, n) {
-                $.PrivateBin.I18n.reset(language);
-                var result = $.PrivateBin.I18n.getPluralForm(n);
-                // arabic seems to have the highest plural count with 6 forms
-                return result >= 0 && result <= 5;
-            }
-        );
+        it('returns valid key for plural form', () => {
+            fc.assert(fc.property(
+                common.fcSupportedLanguages(),
+                fc.integer(),
+                function(language, n) {
+                    PrivateBin.I18n.reset(language);
+                    var result = PrivateBin.I18n.getPluralForm(n);
+                    // arabic seems to have the highest plural count with 6 forms
+                    return result >= 0 && result <= 5;
+                }
+            ));
+        });
     });
 
     // loading of JSON via AJAX needs to be tested in the browser, this just mocks it
@@ -179,53 +190,53 @@ describe('I18n', function () {
     describe('loadTranslations', function () {
         this.timeout(30000);
         before(function () {
-            $.PrivateBin.I18n.reset();
+            PrivateBin.I18n.reset();
         });
 
-        jsc.property(
-            'downloads and handles any supported language',
-            common.jscSupportedLanguages(),
-            function(language) {
-                // cleanup
-                var clean = jsdom('', {cookie: ['lang=en']});
-                $.PrivateBin.I18n.reset('en');
-                $.PrivateBin.I18n.loadTranslations();
-                clean();
-
-                // mock
-                clean = jsdom('', {cookie: ['lang=' + language]});
-                // eslint-disable-next-line global-require
-                $.PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
-                var loadedLang = $.PrivateBin.I18n.getLanguage(),
-                    result = $.PrivateBin.I18n.translate('Never'),
-                    alias  = $.PrivateBin.I18n._('Never');
-                clean();
-                return language === loadedLang && result === alias;
-            }
-        );
-
-        jsc.property(
-            'should default to en',
-            function() {
-                var clean = jsdom('', {url: 'https://privatebin.net/'});
-
-                // when navigator.userLanguage is undefined and no default language
-                // is specified, it would throw an error
-                [ 'language', 'userLanguage' ].forEach(function (key) {
-                    Object.defineProperty(navigator, key, {
-                        value: undefined,
-                        writeable: false
-                    });
+        it('downloads and handles any supported language', () => {
+            fc.assert(fc.property(
+                common.fcSupportedLanguages(),
+                function(language) {
+                    // cleanup
+                    var clean = globalThis.cleanup('', {cookie: ['lang=en']});
+                    PrivateBin.I18n.reset('en');
+                    PrivateBin.I18n.loadTranslations();
+                    clean();
+
+                    // mock
+                    clean = globalThis.cleanup('', {cookie: ['lang=' + language]});
+                    // eslint-disable-next-line global-require
+                    PrivateBin.I18n.reset(language, require('../../i18n/' + language + '.json'));
+                    var loadedLang = PrivateBin.I18n.getLanguage(),
+                        result = PrivateBin.I18n.translate('Never'),
+                        alias  = PrivateBin.I18n._('Never');
+                    clean();
+                    return language === loadedLang && result === alias;
+                }
+            ));
+        });
+
+        it('should default to en', () => {
+            var clean = globalThis.cleanup('', {url: 'https://privatebin.net/'});
+
+            // when navigator.userLanguage is undefined and no default language
+            // is specified, it would throw an error
+            [ 'language', 'userLanguage' ].forEach(function (key) {
+                Object.defineProperty(navigator, key, {
+                    value: undefined,
+                    configurable: true,
+                    enumerable: true,
+                    writable: false
                 });
+            });
 
-                $.PrivateBin.I18n.reset('en');
-                $.PrivateBin.I18n.loadTranslations();
-                var result = $.PrivateBin.I18n.translate('Never'),
-                    alias  = $.PrivateBin.I18n._('Never');
+            PrivateBin.I18n.reset('en');
+            PrivateBin.I18n.loadTranslations();
+            var result = PrivateBin.I18n.translate('Never'),
+                alias  = PrivateBin.I18n._('Never');
 
-                clean();
-                return 'Never' === result && 'Never' === alias;
-            }
-        );
+            clean();
+            return 'Never' === result && 'Never' === alias;
+        });
     });
 });

+ 211 - 197
js/test/Model.js

@@ -1,243 +1,257 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('Model', function () {
     describe('getExpirationDefault', function () {
         before(function () {
-            $.PrivateBin.Model.reset();
-            cleanup = jsdom();
+            PrivateBin.Model.reset();
+            globalThis.cleanup();
         });
 
-        jsc.property(
-            'returns the contents of the element with id "pasteExpiration"',
-            'nearray asciinestring',
-            'string',
-            'small nat',
-            function (keys, value, key) {
-                keys = keys.map($.PrivateBin.Helper.htmlEntities);
-                value = $.PrivateBin.Helper.htmlEntities(value);
-                var content = keys.length > key ? keys[key] : keys[0],
-                    contents = '<select id="pasteExpiration" name="pasteExpiration">';
-                keys.forEach(function(item) {
-                    contents += '<option value="' + item + '"';
-                    if (item === content) {
-                        contents += ' selected="selected"';
-                    }
-                    contents += '>' + value + '</option>';
-                });
-                contents += '</select>';
-                $('body').html(contents);
-                var result = $.PrivateBin.Helper.htmlEntities(
-                    $.PrivateBin.Model.getExpirationDefault()
-                );
-                $.PrivateBin.Model.reset();
-                return content === result;
-            }
-        );
+        it('returns the contents of the element with id "pasteExpiration"', () => {
+            fc.assert(fc.property(
+                fc.array(fc.string({minLength: 1}), {minLength: 1}),
+                fc.string(),
+                fc.nat(),
+                function (keys, value, key) {
+                    keys = keys.map(PrivateBin.Helper.htmlEntities);
+                    value = PrivateBin.Helper.htmlEntities(value);
+                    var content = keys.length > key ? keys[key] : keys[0],
+                        contents = '<select id="pasteExpiration" name="pasteExpiration">';
+                    keys.forEach(function(item) {
+                        contents += '<option value="' + item + '"';
+                        if (item === content) {
+                            contents += ' selected="selected"';
+                        }
+                        contents += '>' + value + '</option>';
+                    });
+                    contents += '</select>';
+                    document.body.innerHTML = contents;
+                    var result = PrivateBin.Helper.htmlEntities(
+                        PrivateBin.Model.getExpirationDefault()
+                    );
+                    PrivateBin.Model.reset();
+                    return content === result;
+                }
+            ));
+        });
     });
 
     describe('getFormatDefault', function () {
         before(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
         after(function () {
-            cleanup();
+            globalThis.cleanup();
         });
 
-        jsc.property(
-            'returns the contents of the element with id "pasteFormatter"',
-            'nearray asciinestring',
-            'string',
-            'small nat',
-            function (keys, value, key) {
-                keys = keys.map($.PrivateBin.Helper.htmlEntities);
-                value = $.PrivateBin.Helper.htmlEntities(value);
-                var content = keys.length > key ? keys[key] : keys[0],
-                    contents = '<select id="pasteFormatter" name="pasteFormatter">';
-                keys.forEach(function(item) {
-                    contents += '<option value="' + item + '"';
-                    if (item === content) {
-                        contents += ' selected="selected"';
-                    }
-                    contents += '>' + value + '</option>';
-                });
-                contents += '</select>';
-                $('body').html(contents);
-                var result = $.PrivateBin.Helper.htmlEntities(
-                    $.PrivateBin.Model.getFormatDefault()
-                );
-                $.PrivateBin.Model.reset();
-                return content === result;
-            }
-        );
+        it('returns the contents of the element with id "pasteFormatter"', () => {
+            fc.assert(fc.property(
+                fc.array(fc.string({minLength: 1}), {minLength: 1}),
+                fc.string(),
+                fc.nat(),
+                function (keys, value, key) {
+                    keys = keys.map(PrivateBin.Helper.htmlEntities);
+                    value = PrivateBin.Helper.htmlEntities(value);
+                    var content = keys.length > key ? keys[key] : keys[0],
+                        contents = '<select id="pasteFormatter" name="pasteFormatter">';
+                    keys.forEach(function(item) {
+                        contents += '<option value="' + item + '"';
+                        if (item === content) {
+                            contents += ' selected="selected"';
+                        }
+                        contents += '>' + value + '</option>';
+                    });
+                    contents += '</select>';
+                    document.body.innerHTML = contents;
+                    var result = PrivateBin.Helper.htmlEntities(
+                        PrivateBin.Model.getFormatDefault()
+                    );
+                    PrivateBin.Model.reset();
+                    return content === result;
+                }
+            ));
+        });
     });
 
     describe('getPasteId', function () {
         this.timeout(30000);
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
 
-        jsc.property(
-            'returns the query string without separator, if any',
-            common.jscUrl(true, false),
-            jsc.tuple(new Array(16).fill(common.jscHexString)),
-            jsc.array(common.jscQueryString()),
-            jsc.array(common.jscQueryString()),
-            function (url, pasteId, queryStart, queryEnd) {
-                if (queryStart.length > 0) {
-                    queryStart.push('&');
-                }
-                if (queryEnd.length > 0) {
-                    queryEnd.unshift('&');
-                }
-                url.query = queryStart.concat(pasteId, queryEnd);
-                const pasteIdString = pasteId.join(''),
-                    clean           = jsdom('', {url: common.urlToString(url)});
-                const result = $.PrivateBin.Model.getPasteId();
-                $.PrivateBin.Model.reset();
-                clean();
-                return pasteIdString === result;
-            }
-        );
-        jsc.property(
-            'throws exception on empty query string',
-            common.jscUrl(true, false),
-            function (url) {
-                const clean = jsdom('', {url: common.urlToString(url)});
-                let result = false;
-                try {
-                    $.PrivateBin.Model.getPasteId();
+        it('returns the query string without separator, if any', () => {
+            fc.assert(fc.property(
+                common.fcUrl(true, false),
+                fc.array(common.fcHexString(), {minLength: 16, maxLength: 16}),
+                fc.array(common.fcQueryString()),
+                fc.array(common.fcQueryString()),
+                function (url, pasteId, queryStart, queryEnd) {
+                    if (queryStart.length > 0) {
+                        queryStart.push('&');
+                    }
+                    if (queryEnd.length > 0) {
+                        queryEnd.unshift('&');
+                    }
+                    url.query = queryStart.concat(pasteId, queryEnd);
+                    const pasteIdString = pasteId.join(''),
+                        clean           = globalThis.cleanup('', {url: common.urlToString(url)});
+                    const result = PrivateBin.Model.getPasteId();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return pasteIdString === result;
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('throws exception on empty query string', () => {
+            fc.assert(fc.property(
+                common.fcUrl(true, false),
+                function (url) {
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    let result = false;
+                    try {
+                        PrivateBin.Model.getPasteId();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
                 }
-                $.PrivateBin.Model.reset();
-                clean();
-                return result;
-            }
-        );
+            ));
+        });
     });
 
     describe('getPasteKey', function () {
         this.timeout(30000);
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
 
-        jsc.property(
-            'throws exception on v1 URLs',
-            common.jscUrl(),
-            function (url) {
-                url.fragment = '0OIl'; // any non-base58 string
-                const clean = jsdom('', {url: common.urlToString(url)});
-                let result = false;
-                try {
-                    $.PrivateBin.Model.getPasteId();
+        it('throws exception on v1 URLs', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    url.fragment = '0OIl'; // any non-base58 string
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    let result = false;
+                    try {
+                        PrivateBin.Model.getPasteId();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('returns the fragment stripped of trailing query parts', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                function (url, trail) {
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
                 }
-                $.PrivateBin.Model.reset();
-                clean();
-                return result;
-            }
-        );
-        jsc.property(
-            'returns the fragment stripped of trailing query parts',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            function (url, trail) {
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'returns the fragment of a v2 URL',
-            common.jscUrl(),
-            function (url) {
-                // base58 strips leading NULL bytes, so the string is padded with these if not found
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment);
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'returns the v2 fragment stripped of trailing query parts',
-            common.jscUrl(),
-            jsc.array(common.jscHashString()),
-            function (url, trail) {
-                // base58 strips leading NULL bytes, so the string is padded with these if not found
-                const fragment = url.fragment.padStart(32, '\u0000');
-                url.fragment = $.PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
-                const clean = jsdom('', {url: common.urlToString(url)}),
-                    result = $.PrivateBin.Model.getPasteKey();
-                $.PrivateBin.Model.reset();
-                clean();
-                return fragment === result;
-            }
-        );
-        jsc.property(
-            'throws exception on empty fragment of the URL',
-            common.jscUrl(false),
-            function (url) {
-                let clean = jsdom('', {url: common.urlToString(url)}),
-                    result = false;
-                try {
-                    $.PrivateBin.Model.getPasteKey();
+            ));
+        });
+        it('returns the fragment of a v2 URL', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    // base58 strips leading NULL bytes, so the string is padded with these if not found
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment);
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
                 }
-                catch(err) {
-                    result = true;
+            ));
+        });
+        it('returns the v2 fragment stripped of trailing query parts', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                fc.array(common.fcHashString()),
+                function (url, trail) {
+                    // base58 strips leading NULL bytes, so the string is padded with these if not found
+                    const fragment = url.fragment.padStart(32, '\u0000');
+                    url.fragment = PrivateBin.CryptTool.base58encode(fragment) + '&' + trail.join('');
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = PrivateBin.Model.getPasteKey();
+                    PrivateBin.Model.reset();
+                    clean();
+                    return fragment === result;
+                }
+            ));
+        });
+        it('throws exception on empty fragment of the URL', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false),
+                function (url) {
+                    let clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result = false;
+                    try {
+                        PrivateBin.Model.getPasteKey();
+                    }
+                    catch(err) {
+                        result = true;
+                    }
+                    PrivateBin.Model.reset();
+                    clean();
+                    return result;
                 }
-                $.PrivateBin.Model.reset();
-                clean();
-                return result;
-            }
-        );
+            ));
+        });
     });
 
     describe('getTemplate', function () {
         beforeEach(function () {
-            $.PrivateBin.Model.reset();
+            PrivateBin.Model.reset();
         });
 
-        jsc.property(
-            'returns the contents of the element with id "[name]template"',
-            jsc.nearray(common.jscAlnumString()),
-            jsc.nearray(common.jscA2zString()),
-            jsc.nearray(common.jscAlnumString()),
-            function (id, element, value) {
-                id = id.join('');
-                element = element.join('');
-                value = value.join('').trim();
+        it('returns the contents of the element with id "[name]template"', () => {
+            fc.assert(fc.property(
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                fc.array(common.fcA2zString(), {minLength: 1}),
+                fc.array(common.fcAlnumString(), {minLength: 1}),
+                function (id, element, value) {
+                    id = id.join('');
+                    element = element.join('');
+                    value = value.join('').trim();
 
-                // <br>, <hr>, <img> and <wbr> tags can't contain strings,
-                // table tags can't be alone, so test with a <p> instead
-                if (['br', 'col', 'hr', 'img', 'tr', 'td', 'th', 'wbr'].indexOf(element) >= 0) {
-                    element = 'p';
-                }
+                    // <br>, <hr>, <img> and <wbr> tags can't contain strings,
+                    // table tags can't be alone, so test with a <p> instead
+                    if (['br', 'col', 'hr', 'img', 'tr', 'td', 'th', 'wbr'].indexOf(element) >= 0) {
+                        element = 'p';
+                    }
 
-                $('body').html(
-                    '<div id="templates"><' + element + ' id="' + id +
-                    'template">' + value + '</' + element + '></div>'
-                );
-                $.PrivateBin.Model.init();
-                var template = '<' + element + ' id="' + id + '">' + value +
-                    '</' + element + '>',
-                    result = $.PrivateBin.Model.getTemplate(id).wrap('<p/>').parent().html();
-                $.PrivateBin.Model.reset();
-                return template === result;
-            }
-        );
+                    document.body.innerHTML = (
+                        '<div id="templates"><' + element + ' id="' + id +
+                        'template">' + value + '</' + element + '></div>'
+                    );
+                    PrivateBin.Model.init();
+                    var template = '<' + element + ' id="' + id + '">' + value +
+                        '</' + element + '>',
+                        templateEl = PrivateBin.Model.getTemplate(id),
+                        wrapper = document.createElement('p');
+                    wrapper.appendChild(templateEl.cloneNode(true));
+                    var result = wrapper.innerHTML;
+                    PrivateBin.Model.reset();
+                    return template === result;
+                }
+            ));
+        });
     });
 });
 

+ 207 - 177
js/test/PasteStatus.js

@@ -1,5 +1,6 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 function urlStrings(schema, longUrl, shortUrl) {
     longUrl.schema = schema;
@@ -22,37 +23,57 @@ describe('PasteStatus', function () {
     describe('createPasteNotification', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'creates a notification after a successful document upload',
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (url1, url2) {
-                const expected1 = common.urlToString(url1).replace(/&(gt|lt)$/, '&$1a'),
-                    expected2 = common.urlToString(url2).replace(/&(gt|lt)$/, '&$1a'),
-                    clean = jsdom();
-                $('body').html('<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
-                const result1 = $('#pasteurl')[0].href,
-                    result2 = $('#deletelink')[0].href;
-                clean();
-                return result1 === expected1 && result2 === expected2;
-            }
-        );
+        it('creates a notification after a successful document upload', function () {
+            cleanup();
+            document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div><div id="pastesuccess"></div>';
+            PrivateBin.PasteStatus.init();
+            const expected1 = 'https://example.com/long';
+            const expected2 = 'https://example.com/short';
+            PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
+
+            assert.strictEqual(document.getElementById('pasteurl').href, expected1);
+            assert.strictEqual(document.getElementById('deletelink').href, expected2);
+            assert.ok(!document.getElementById('pastesuccess').classList.contains('hidden'));
+        });
+
+        it('creates a notification after a successful document upload (jsc)', () => {
+            fc.assert(fc.asyncProperty(
+                common.fcUrl(),
+                common.fcUrl(false),
+                async function (url1, url2) {
+                    // sometimes the generator returns incomplete objects, bail out
+                    if (!url1 || !url1.address || !url2 || !url2.address) {
+                        return true;
+                    }
+                    const expected1 = common.urlToString(url1).replace(/&(gt|lt)$/, '&$1a'),
+                        expected2 = common.urlToString(url2).replace(/&(gt|lt)$/, '&$1a');
+                    globalThis.cleanup();
+                    document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div><div id="pastesuccess"></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification(expected1, expected2);
+
+                    assert.ok(!document.getElementById('pastesuccess').classList.contains('hidden'));
+
+                    const result2 = document.getElementById('deletelink').href;
+                    return document.getElementById('pasteurl').href === expected1 && result2 === expected2;
+                }
+            ));
+        });
 
         it(
             'shows Cmd hotkey hint on macOS',
             function () {
-                const clean = jsdom();
+                cleanup();
                 Object.defineProperty(navigator, 'userAgent', {
                     value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
                     configurable: true
                 });
-                $('body').html('<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
-                const hotkey = $('#copyhint kbd').first().text();
-                clean();
+                document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>';
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
+
+                const hotkey = document.querySelector('#copyhint kbd').textContent;
+
                 assert.strictEqual(hotkey, 'Cmd');
             }
         );
@@ -60,16 +81,19 @@ describe('PasteStatus', function () {
         it(
             'shows Ctrl hotkey hint on non-Mac platforms',
             function () {
-                const clean = jsdom();
+                cleanup();
+
                 Object.defineProperty(navigator, 'userAgent', {
                     value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                     configurable: true
                 });
-                $('body').html('<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
-                const hotkey = $('#copyhint kbd').first().text();
-                clean();
+                document.body.innerHTML = '<a href="#" id="deletelink"><span></span></a><div id="pastelink"></div>';
+
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.createPasteNotification('https://example.com/', 'https://example.com/delete');
+
+                const hotkey = document.querySelector('#copyhint kbd').textContent;
+
                 assert.strictEqual(hotkey, 'Ctrl');
             }
         );
@@ -78,181 +102,187 @@ describe('PasteStatus', function () {
     describe('extractUrl', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'extracts and updates IDN URLs found in given response',
-            common.jscSchemas(false),
-            'nestring',
-            common.jscUrl(),
-            function (schema, domain, url) {
-                domain = domain.replace(/\P{Letter}|[\u{AA}-\u{BA}]/gu, '').toLowerCase();
-                if (domain.length === 0) {
-                    domain = 'a';
+        it('extracts and updates IDN URLs found in given response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                fc.string({minLength: 1}),
+                common.fcUrl(),
+                function (schema, domain, url) {
+                    domain = domain.replace(/\P{Letter}|[\u{AA}-\u{BA}]/gu, '').toLowerCase();
+                    if (domain.length === 0) {
+                        domain = 'a';
+                    }
+                    url.schema = schema;
+                    url.address.unshift('.');
+                    url.address = domain.split('').concat(url.address);
+                    const urlString = common.urlToString(url),
+                        expected = urlString.substring((schema + '://' + domain).length),
+                        clean = globalThis.cleanup();
+
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(urlString);
+
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
+
+                    return result.endsWith(expected) && (
+                        result.startsWith(schema + '://xn--') ||
+                        result.startsWith(schema + '://' + domain)
+                    );
                 }
-                url.schema = schema;
-                url.address.unshift('.');
-                url.address = domain.split('').concat(url.address);
-                const urlString = common.urlToString(url),
-                    expected = urlString.substring((schema + '://' + domain).length),
-                    clean = jsdom();
-
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(urlString);
-
-                const result = $('#pasteurl')[0].href;
-                clean();
-
-                return result.endsWith(expected) && (
-                    result.startsWith(schema + '://xn--') ||
-                    result.startsWith(schema + '://' + domain)
-                );
-            }
-        );
+            ));
+        });
 
         // YOURLS API samples from: https://yourls.org/readme.html#API;apireturn
-        jsc.property(
-            'extracts and updates URLs found in YOURLS API JSON response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = {
-                        url: {
-                            keyword: longUrl.address.join(''),
-                            url: longUrlString,
+        it('extracts and updates URLs found in YOURLS API JSON response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = {
+                            url: {
+                                keyword: longUrl.address.join(''),
+                                url: longUrlString,
+                                title: 'example title',
+                                date: '2014-10-24 16:01:39',
+                                ip: '127.0.0.1'
+                            },
+                            status: 'success',
+                            message: longUrlString + ' added to database',
                             title: 'example title',
-                            date: '2014-10-24 16:01:39',
-                            ip: '127.0.0.1'
+                            shorturl: shortUrlString,
+                            statusCode: 200
                         },
-                        status: 'success',
-                        message: longUrlString + ' added to database',
-                        title: 'example title',
-                        shorturl: shortUrlString,
-                        statusCode: 200
-                    },
-                    clean = jsdom();
-
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(JSON.stringify(yourlsResponse, undefined, 4));
-
-                const result = $('#pasteurl')[0].href;
-                clean();
-
-                return result === shortUrlString;
-            }
-        );
-        jsc.property(
-            'extracts and updates URLs found in YOURLS API XML response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = '<result>\n' +
-                        '    <keyword>' + longUrl.address.join('') + '</keyword>\n' +
-                        '    <shorturl>' + shortUrlString + '</shorturl>\n' +
-                        '    <longurl>' + longUrlString + '</longurl>\n' +
-                        '    <message>success</message>\n' +
-                        '    <statusCode>200</statusCode>\n' +
+                        clean = globalThis.cleanup();
+
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(JSON.stringify(yourlsResponse, undefined, 4));
+
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
+
+                    return result === shortUrlString;
+                }
+            ));
+        });
+        it('extracts and updates URLs found in YOURLS API XML response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [longUrlString, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = '<result>\n' +
+                            '    <keyword>' + longUrl.address.join('') + '</keyword>\n' +
+                            '    <shorturl>' + shortUrlString + '</shorturl>\n' +
+                            '    <longurl>' + longUrlString + '</longurl>\n' +
+                            '    <message>success</message>\n' +
+                            '    <statusCode>200</statusCode>\n' +
                         '</result>',
-                    clean = jsdom();
+                        clean = globalThis.cleanup();
 
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(yourlsResponse);
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(yourlsResponse);
 
-                const result = $('#pasteurl')[0].href;
-                clean();
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
 
-                return result === shortUrlString;
-            }
-        );
-        jsc.property(
-            'extracts and updates URLs found in YOURLS proxy HTML response',
-            common.jscSchemas(false),
-            common.jscUrl(),
-            common.jscUrl(false),
-            function (schema, longUrl, shortUrl) {
-                const [_, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
-                    yourlsResponse = '<!DOCTYPE html>\n' +
-                        '<html lang="en">\n' +
-                        '\t<head>\n' +
-                        '\t\t<meta charset="utf-8" />\n' +
-                        '\t\t<meta name="robots" content="noindex" />\n' +
-                        '\t\t<meta name="google" content="notranslate">\n' +
-                        '\t\t<title>PrivateBin</title>\n' +
-                        '\t</head>\n' +
-                        '\t<body>\n' +
-                        '\t\t<p>Your document is <a id="pasteurl" href="' + shortUrlString + '">' + shortUrlString + '</a> <span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></p>\n' +
-                        '\t</body>\n' +
+                    return result === shortUrlString;
+                }
+            ));
+        });
+        it('extracts and updates URLs found in YOURLS proxy HTML response', () => {
+            fc.assert(fc.property(
+                common.fcSchemas(false),
+                common.fcUrl(),
+                common.fcUrl(false),
+                function (schema, longUrl, shortUrl) {
+                    const [_, shortUrlString] = urlStrings(schema, longUrl, shortUrl),
+                        yourlsResponse = '<!DOCTYPE html>\n' +
+                            '<html lang="en">\n' +
+                            '\t<head>\n' +
+                            '\t\t<meta charset="utf-8" />\n' +
+                            '\t\t<meta name="robots" content="noindex" />\n' +
+                            '\t\t<meta name="google" content="notranslate">\n' +
+                            '\t\t<title>PrivateBin</title>\n' +
+                            '\t</head>\n' +
+                            '\t<body>\n' +
+                            '\t\t<p>Your document is <a id="pasteurl" href="' + shortUrlString + '">' + shortUrlString + '</a> <span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span></p>\n' +
+                            '\t</body>\n' +
                         '</html>',
-                    clean = jsdom();
+                        clean = globalThis.cleanup();
 
-                $('body').html('<div><div id="pastelink"></div></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.createPasteNotification('', '');
-                $.PrivateBin.PasteStatus.extractUrl(yourlsResponse);
+                    document.body.innerHTML = '<div><div id="pastelink"></div></div>';
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.createPasteNotification('', '');
+                    PrivateBin.PasteStatus.extractUrl(yourlsResponse);
 
-                const result = $('#pasteurl')[0].href;
-                clean();
+                    const result = document.getElementById('pasteurl').href;
+                    clean();
 
-                return result === shortUrlString;
-            }
-        );
+                    return result === shortUrlString;
+                }
+            ));
+        });
     });
 
     describe('showRemainingTime', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'shows burn after reading message or remaining time',
-            'bool',
-            'nat',
-            common.jscUrl(),
-            function (burnafterreading, remainingTime, url) {
-                let clean = jsdom('', {url: common.urlToString(url)}),
-                    result;
-                $('body').html('<div id="remainingtime" class="hidden"></div>');
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.showRemainingTime($.PrivateBin.Helper.PasteFactory({
-                    'adata': [null, null, null, burnafterreading],
-                    'v': 2,
-                    'meta': {
-                        'time_to_live': remainingTime
+        it('shows burn after reading message or remaining time', () => {
+            fc.assert(fc.property(
+                fc.boolean(),
+                fc.nat(),
+                common.fcUrl(),
+                function (burnafterreading, remainingTime, url) {
+                    let clean = globalThis.cleanup('', {url: common.urlToString(url)}),
+                        result;
+                    document.body.innerHTML = '<div id="remainingtime" class="hidden"></div>';
+                    PrivateBin.Alert.init();
+                    PrivateBin.PasteStatus.init();
+                    PrivateBin.PasteStatus.showRemainingTime(PrivateBin.Helper.PasteFactory({
+                        'adata': [null, null, null, burnafterreading],
+                        'v': 2,
+                        'meta': {
+                            'time_to_live': remainingTime
+                        }
+                    }));
+                    if (burnafterreading) {
+                        result = document.getElementById('remainingtime').classList.contains('foryoureyesonly') &&
+                                !document.getElementById('remainingtime').classList.contains('hidden');
+                    } else if (remainingTime) {
+                        result =!document.getElementById('remainingtime').classList.contains('foryoureyesonly') &&
+                                !document.getElementById('remainingtime').classList.contains('hidden');
+                    } else {
+                        result = document.getElementById('remainingtime').classList.contains('hidden') &&
+                                !document.getElementById('remainingtime').classList.contains('foryoureyesonly');
                     }
-                }));
-                if (burnafterreading) {
-                    result = $('#remainingtime').hasClass('foryoureyesonly') &&
-                            !$('#remainingtime').hasClass('hidden');
-                } else if (remainingTime) {
-                    result =!$('#remainingtime').hasClass('foryoureyesonly') &&
-                            !$('#remainingtime').hasClass('hidden');
-                } else {
-                    result = $('#remainingtime').hasClass('hidden') &&
-                            !$('#remainingtime').hasClass('foryoureyesonly');
+                    clean();
+                    return result;
                 }
-                clean();
-                return result;
-            }
-        );
+            ));
+        });
     });
 
     describe('hideMessages', function () {
         it(
             'hides all messages',
             function() {
-                $('body').html(
+                document.body.innerHTML = (
                     '<div id="remainingtime"></div><div id="pastesuccess"></div>'
                 );
-                $.PrivateBin.PasteStatus.init();
-                $.PrivateBin.PasteStatus.hideMessages();
-                assert.ok($('#remainingtime').hasClass('hidden'));
-                assert.ok($('#pastesuccess').hasClass('hidden'));
+                PrivateBin.PasteStatus.init();
+                PrivateBin.PasteStatus.hideMessages();
+                assert.ok(document.getElementById('remainingtime').classList.contains('hidden'));
+                assert.ok(document.getElementById('pastesuccess').classList.contains('hidden'));
             }
         );
     });

+ 147 - 104
js/test/PasteViewer.js

@@ -1,118 +1,161 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('PasteViewer', function () {
     describe('run, hide, getText, setText, getFormat, setFormat & isPrettyPrinted', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'displays text according to format',
-            common.jscFormats(),
-            'nestring',
-            function (format, text) {
-                var clean = jsdom(),
-                    results = [];
-                $('body').html(
-                    '<div id="placeholder" class="hidden">+++ no document text ' +
-                    '+++</div><div id="prettymessage" class="hidden"><pre ' +
-                    'id="prettyprint" class="prettyprint linenums:1"></pre>' +
-                    '</div><div id="plaintext" class="hidden"></div>'
-                );
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText('');
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden') &&
-                    $.PrivateBin.PasteViewer.getFormat() === format &&
-                    $.PrivateBin.PasteViewer.getText() === ''
-                );
-                $.PrivateBin.PasteViewer.run();
-                results.push(
-                    !$('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden')
-                );
-                $.PrivateBin.PasteViewer.hide();
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    $('#prettymessage').hasClass('hidden') &&
-                    $('#plaintext').hasClass('hidden')
-                );
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-                results.push(
-                    $('#placeholder').hasClass('hidden') &&
-                    !$.PrivateBin.PasteViewer.isPrettyPrinted() &&
-                    $.PrivateBin.PasteViewer.getText() === text
-                );
-                if (format === 'markdown') {
+        beforeEach(() => {
+            document.body.innerHTML = (
+                '<div id="placeholder" class="hidden">+++ no document text +++</div>' +
+                '<div id="prettymessage" class="hidden"><pre id="prettyprint" class="prettyprint linenums:1"></pre></div>' +
+                '<div id="plaintext" class="hidden"></div>'
+            );
+        });
+
+        afterEach(() => {
+            globalThis.cleanup();
+        });
+
+        it('basic plaintext display works', function () {
+            PrivateBin.PasteViewer.init();
+            PrivateBin.PasteViewer.setFormat('plaintext');
+            PrivateBin.PasteViewer.setText('hello');
+            PrivateBin.PasteViewer.run();
+            assert.strictEqual(PrivateBin.PasteViewer.getText(), 'hello');
+            assert.ok(!document.getElementById('prettymessage').classList.contains('hidden'));
+        });
+
+        it('basic markdown display works', function () {
+            PrivateBin.PasteViewer.init();
+            PrivateBin.PasteViewer.setFormat('markdown');
+            PrivateBin.PasteViewer.setText('hello **bold**');
+            PrivateBin.PasteViewer.run();
+            assert.strictEqual(PrivateBin.PasteViewer.getText(), 'hello **bold**');
+            assert.ok(!document.getElementById('plaintext').classList.contains('hidden'));
+        });
+
+        it('initializes with empty text and shows nothing', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                function (format) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText('');
                     results.push(
-                        $('#prettymessage').hasClass('hidden') &&
-                        !$('#plaintext').hasClass('hidden')
+                        document.getElementById('placeholder').classList.contains('hidden') &&
+                        document.getElementById('prettymessage').classList.contains('hidden') &&
+                        document.getElementById('plaintext').classList.contains('hidden') &&
+                        PrivateBin.PasteViewer.getFormat() === format &&
+                        PrivateBin.PasteViewer.getText() === ''
                     );
-                } else {
+                    return results.every(element => element);
+                }
+            ));
+        });
+
+        it('when no text is given and view is rendered, it shows placeholder', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                function (format) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText('');
+                    PrivateBin.PasteViewer.run();
                     results.push(
-                        !$('#prettymessage').hasClass('hidden') &&
-                        $('#plaintext').hasClass('hidden')
+                        !document.getElementById('placeholder').classList.contains('hidden') &&
+                        document.getElementById('prettymessage').classList.contains('hidden') &&
+                        document.getElementById('plaintext').classList.contains('hidden')
                     );
+                    return results.every(element => element);
                 }
-                clean();
-                return results.every(element => element);
-            }
-        );
+            ));
+        });
 
-        jsc.property(
-            'sanitizes XSS',
-            common.jscFormats(),
-            'string',
-            // @see    {@link https://www.owasp.org/index.php/XSS_Filter_Evasion_Cheat_Sheet}
-            jsc.elements([
-                '<PLAINTEXT>',
-                '></SCRIPT>">\'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>',
-                '\'\';!--"<XSS>=&{()}',
-                '<SCRIPT SRC=http://example.com/xss.js></SCRIPT>',
-                '\'">><marquee><img src=x onerror=confirm(1)></marquee>">' +
-                '</plaintext\\></|\\><plaintext/onmouseover=prompt(1)>' +
-                '<script>prompt(1)</script>@gmail.com<isindex formaction=java' +
-                // obfuscate script URL from eslint rule: no-script-url
-                'script:alert(/XSS/) type=submit>\'-->"></script>' +
-                '<script>alert(document.cookie)</script>"><img/id="confirm' +
-                '&lpar;1)"/alt="/"src="/"onerror=eval(id)>\'">',
-                '<IMG SRC="javascript:alert(\'XSS\');">',
-                '<IMG SRC=javascript:alert(\'XSS\')>',
-                '<IMG SRC=JaVaScRiPt:alert(\'XSS\')>',
-                '<IMG SRC=javascript:alert(&quot;XSS&quot;)>',
-                '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
-                '<a onmouseover="alert(document.cookie)">xxs link</a>',
-                '<a onmouseover=alert(document.cookie)>xxs link</a>',
-                '<IMG """><SCRIPT>alert("XSS")</SCRIPT>">',
-                '<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>',
-                '<IMG STYLE="xss:expr/*XSS*/ession(alert(\'XSS\'))">',
-                '<FRAMESET><FRAME SRC="javascript:alert(\'XSS\');"></FRAMESET>',
-                '<TABLE BACKGROUND="javascript:alert(\'XSS\')">',
-                '<TABLE><TD BACKGROUND="javascript:alert(\'XSS\')">',
-                '<SCRIPT>document.write("<SCRI");</SCRIPT>PT SRC="httx://xss.rocks/xss.js"></SCRIPT>'
-            ]),
-            'string',
-            function (format, prefix, xss, suffix) {
-                var clean = jsdom(),
-                    text = prefix + xss + suffix;
-                $('body').html(
-                    '<div id="placeholder" class="hidden">+++ no document text ' +
-                    '+++</div><div id="prettymessage" class="hidden"><pre ' +
-                    'id="prettyprint" class="prettyprint linenums:1"></pre>' +
-                    '</div><div id="plaintext" class="hidden"></div>'
-                );
-                $.PrivateBin.PasteViewer.init();
-                $.PrivateBin.PasteViewer.setFormat(format);
-                $.PrivateBin.PasteViewer.setText(text);
-                $.PrivateBin.PasteViewer.run();
-                var result = $('body').html().indexOf(xss) === -1;
-                clean();
-                return result;
-            }
-        );
+        it('displays text according to format', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                fc.string({minLength: 1}),
+                function (format, text) {
+                    var results = [];
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+                    results.push(
+                        document.getElementById('placeholder').classList.contains('hidden') &&
+                        !PrivateBin.PasteViewer.isPrettyPrinted() &&
+                        PrivateBin.PasteViewer.getText() === text
+                    );
+                    if (format === 'markdown') {
+                        results.push(
+                            document.getElementById('prettymessage').classList.contains('hidden') &&
+                            !document.getElementById('plaintext').classList.contains('hidden')
+                        );
+                    } else {
+                        results.push(
+                            !document.getElementById('prettymessage').classList.contains('hidden') &&
+                            document.getElementById('plaintext').classList.contains('hidden')
+                        );
+                    }
+
+                    return results.every(element => element);
+                }
+            ));
+        });
+
+        it('sanitizes XSS', () => {
+            fc.assert(fc.property(
+                common.fcFormats(),
+                fc.string(),
+                fc.constantFrom(
+                    '<PLAINTEXT>',
+                    '></SCRIPT>">\'><SCRIPT>alert(String.fromCharCode(88,83,83))</SCRIPT>',
+                    '\'\';!--"<XSS>=&{()}',
+                    '<SCRIPT SRC=http://example.com/xss.js></SCRIPT>',
+                    '\'">><marquee><img src=x onerror=confirm(1)></marquee>">' +
+                    '</plaintext\\></|\\><plaintext/onmouseover=prompt(1)>' +
+                    '<script>prompt(1)</script>@gmail.com<isindex formaction=java' +
+                    // obfuscate script URL from eslint rule: no-script-url
+                    'script:alert(/XSS/) type=submit>\'-->"></script>' +
+                    '<script>alert(document.cookie)</script>"><img/id="confirm' +
+                    '&lpar;1)"/alt="/"src="/"onerror=eval(id)>\'">',
+                    '<IMG SRC="javascript:alert(\'XSS\');">',
+                    '<IMG SRC=javascript:alert(\'XSS\')>',
+                    '<IMG SRC=JaVaScRiPt:alert(\'XSS\')>',
+                    '<IMG SRC=javascript:alert(&quot;XSS&quot;)>',
+                    '<IMG SRC=`javascript:alert("RSnake says, \'XSS\'")`>',
+                    '<a onmouseover="alert(document.cookie)">xxs link</a>',
+                    '<a onmouseover=alert(document.cookie)>xxs link</a>',
+                    '<IMG """><SCRIPT>alert("XSS")</SCRIPT>">',
+                    '<IMG SRC=javascript:alert(String.fromCharCode(88,83,83))>',
+                    '<IMG STYLE="xss:expr/*XSS*/ession(alert(\'XSS\'))">',
+                    '<FRAMESET><FRAME SRC="javascript:alert(\'XSS\');"></FRAMESET>',
+                    '<TABLE BACKGROUND="javascript:alert(\'XSS\')">',
+                    '<TABLE><TD BACKGROUND="javascript:alert(\'XSS\')">',
+                    '<SCRIPT>document.write("<SCRI");</SCRIPT>PT SRC="httx://xss.rocks/xss.js"></SCRIPT>'
+                ),
+                fc.string(),
+                function (format, prefix, xss, suffix) {
+                    var text = prefix + xss + suffix;
+                    document.body.innerHTML = (
+                        '<div id="placeholder" class="hidden">+++ no document text ' +
+                        '+++</div><div id="prettymessage" class="hidden"><pre ' +
+                        'id="prettyprint" class="prettyprint linenums:1"></pre>' +
+                        '</div><div id="plaintext" class="hidden"></div>'
+                    );
+                    PrivateBin.PasteViewer.init();
+                    PrivateBin.PasteViewer.setFormat(format);
+                    PrivateBin.PasteViewer.setText(text);
+                    PrivateBin.PasteViewer.run();
+                    var result = document.body.innerHTML.indexOf(xss) === -1;
+                    globalThis.cleanup();
+                    return result;
+                }
+            ));
+        });
     });
 });

+ 46 - 32
js/test/Prompt.js

@@ -1,41 +1,55 @@
 'use strict';
 require('../common');
+const fc = require('fast-check');
 
 describe('Prompt', function () {
     describe('requestPassword & getPassword', function () {
         this.timeout(30000);
 
-        jsc.property(
-            'returns the password fed into the dialog',
-            'string',
-            function (password) {
-                password = password.replace(/\r+|\n+/g, '');
-                /* const clean = */ jsdom('', {url: 'ftp://example.com/?0000000000000000'});
-                $('body').html(
-                    '<div id="passwordmodal" class="modal fade" role="dialog">' +
-                    '<div class="modal-dialog"><div class="modal-content">' +
-                    '<div class="modal-body"><form id="passwordform" role="form">' +
-                    '<div class="form-group"><input id="passworddecrypt" ' +
-                    'type="password" class="form-control" placeholder="Enter ' +
-                    'password"></div><button type="submit">Decrypt</button>' +
-                    '</form></div></div></div></div>'
-                );
-                $.PrivateBin.Model.reset();
-                $.PrivateBin.Model.init();
-                // eslint-disable-next-line global-require
-                global.bootstrap = require('../bootstrap-5.3.8');
-                $.PrivateBin.Prompt.init();
-                $.PrivateBin.Prompt.requestPassword();
-                $('#passworddecrypt').val(password);
-                // TODO triggers error messages in current jsDOM version, find better solution
-                //$('#passwordform').submit();
-                //var result = $.PrivateBin.Prompt.getPassword();
-                const result = $('#passworddecrypt').val();
-                $.PrivateBin.Model.reset();
-                // TODO triggers error messages in jsDOM since version 11
-                //clean();
-                return result === password;
-            }
-        );
+        it('returns the password fed into the dialog', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (password) {
+                    password = password.replace(/\r+|\n+/g, '');
+                    const clean = globalThis.cleanup('', {url: 'ftp://example.com/?0000000000000000'});
+                    document.body.innerHTML = `
+                        <div id="passwordmodal" class="modal fade" role="dialog">
+                            <div class="modal-dialog">
+                                <div class="modal-content">
+                                    <div class="modal-body">
+                                        <form id="passwordform" role="form">
+                                            <div class="form-group">
+                                                <input id="passworddecrypt" type="password"
+                                                    class="form-control" placeholder="Enter password">
+                                            </div>
+                                            <button type="submit">Decrypt</button>
+                                        </form>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+                    `;
+
+                    // Initialize the Prompt module to set up event listeners
+                    PrivateBin.Prompt.init();
+                    PrivateBin.Prompt.requestPassword();
+
+                    // Simulate user input
+                    const passwordInput = document.getElementById('passworddecrypt');
+                    passwordInput.value = password;
+
+                    // Simulate form submission to trigger password capture
+                    const passwordForm = document.getElementById('passwordform');
+                    /** {@type SubmitEvent} */
+                    const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
+                    passwordForm.dispatchEvent(submitEvent);
+
+                    // Verify that getPassword returns the submitted password
+                    const result = PrivateBin.Prompt.getPassword();
+                    clean();
+                    return result === password;
+                }
+            ));
+        });
     });
 });

+ 23 - 15
js/test/ServerInteraction.js

@@ -1,5 +1,6 @@
 'use strict';
-require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('ServerInteraction', function () {
     describe('prepare', function () {
@@ -8,33 +9,40 @@ describe('ServerInteraction', function () {
             await new Promise(resolve => setTimeout(resolve, 1900));
         });
         this.timeout(30000);
-        it('can prepare an encrypted document', function () {
-            jsc.assert(jsc.forall(
-                'string',
-                'string',
-                'string',
+        it('can prepare an encrypted document', async function () {
+            await fc.assert(fc.asyncProperty(
+                fc.string(),
+                fc.string(),
+                fc.string(),
                 async function (key, password, message) {
                     // pause to let async functions conclude
                     await new Promise(resolve => setTimeout(resolve, 300));
-                    let clean = jsdom();
-                    window.crypto = new WebCrypto();
+                    let clean = globalThis.cleanup();
+                    Object.defineProperty(window, 'crypto', {
+                        value: new WebCrypto(),
+                        configurable: true,
+                        enumerable: true,
+                        writable: false
+                    });
+                    global.atob = common.atob;
+                    global.btoa = common.btoa;
                     message = message.trim();
 
-                    $.PrivateBin.ServerInteraction.prepare();
-                    $.PrivateBin.ServerInteraction.setCryptParameters(password, key);
-                    $.PrivateBin.ServerInteraction.setUnencryptedData('adata', [
+                    PrivateBin.ServerInteraction.prepare();
+                    PrivateBin.ServerInteraction.setCryptParameters(password, key);
+                    PrivateBin.ServerInteraction.setUnencryptedData('adata', [
                         // encryption parameters defined by CryptTool, format, discussion, burn after reading
                         null, 'plaintext', 0, 0
                     ]);
-                    $.PrivateBin.ServerInteraction.setUnencryptedData('meta', {'expire': '5min'});
-                    await $.PrivateBin.ServerInteraction.setCipherMessage({'paste': message});
-                    //console.log($.PrivateBin.ServerInteraction.getData());
+                    PrivateBin.ServerInteraction.setUnencryptedData('meta', {'expire': '5min'});
+                    await PrivateBin.ServerInteraction.setCipherMessage({'paste': message});
+                    //console.log(PrivateBin.ServerInteraction.getData());
                     clean();
                     // TODO currently not testing anything and just used to generate v2 pastes for starting development of server side v2 implementation
                     return true;
                 }
             ),
-            {tests: 3});
+            {numRuns: 3});
         });
     });
 });

+ 243 - 250
js/test/TopNav.js

@@ -1,5 +1,13 @@
 'use strict';
 require('../common');
+const fc = require('fast-check');
+
+function query(selector) {
+    if (selector.startsWith('#')) {
+        return document.getElementById(selector.slice(1));
+    }
+    return document.querySelector(selector);
+}
 
 describe('TopNav', function () {
     describe('showViewButtons & hideViewButtons', function () {
@@ -11,45 +19,39 @@ describe('TopNav', function () {
             'displays & hides navigation elements for viewing an existing document',
             function () {
                 let results = [];
-                $('body').html(
-                    '<nav class="navbar navbar-inverse navbar-static-top">' +
-                    '<div id="navbar" class="navbar-collapse collapse"><ul ' +
-                    'class="nav navbar-nav"><li><button id="newbutton" ' +
-                    'type="button" class="hidden btn btn-warning navbar-btn">' +
-                    '<span class="glyphicon glyphicon-file" aria-hidden="true">' +
-                    '</span> New</button><button id="clonebutton" type="button"' +
-                    ' class="hidden btn btn-warning navbar-btn">' +
-                    '<span class="glyphicon glyphicon-duplicate" ' +
-                    'aria-hidden="true"></span> Clone</button><button ' +
-                    'id="rawtextbutton" type="button" class="hidden btn ' +
-                    'btn-warning navbar-btn"><span class="glyphicon ' +
-                    'glyphicon-text-background" aria-hidden="true"></span> ' +
-                    'Raw text</button><button id="qrcodelink" type="button" ' +
-                    'data-toggle="modal" data-target="#qrcodemodal" ' +
-                    'class="hidden btn btn-warning navbar-btn"><span ' +
-                    'class="glyphicon glyphicon-qrcode" aria-hidden="true">' +
-                    '</span> QR code</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#clonebutton').hasClass('hidden') &&
-                    $('#rawtextbutton').hasClass('hidden') &&
-                    $('#qrcodelink').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.showViewButtons();
-                results.push(
-                    !$('#newbutton').hasClass('hidden') &&
-                    !$('#clonebutton').hasClass('hidden') &&
-                    !$('#rawtextbutton').hasClass('hidden') &&
-                    !$('#qrcodelink').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.hideViewButtons();
-                results.push(
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#clonebutton').hasClass('hidden') &&
-                    $('#rawtextbutton').hasClass('hidden') &&
-                    $('#qrcodelink').hasClass('hidden')
+                document.documentElement.innerHTML =
+                    `<nav class="navbar navbar-inverse navbar-static-top">
+                    <div id="navbar" class="navbar-collapse collapse">
+                    <ul class="nav navbar-nav"><li><button id="newbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-file" aria-hidden="true">
+                        </span> New</button><button id="clonebutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> Clone</button>
+                    <button id="rawtextbutton" type="button" class="hidden btn btn-warning navbar-btn">
+                    <span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> Raw text</button>
+                    <button id="downloadtextbutton" type="button" class="hidden btn btn-default navbar-btn"></button>
+                    <button id="qrcodelink" type="button" data-toggle="modal" data-target="#qrcodemodal" class="hidden btn btn-warning navbar-btn"/>
+                    <span class="glyphicon glyphicon-qrcode" aria-hidden="true"></span> QR code</button></li></ul></div>
+                    </nav>`;
+                PrivateBin.TopNav.init();
+                results.push(
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#clonebutton').classList.contains('hidden') &&
+                    query('#rawtextbutton').classList.contains('hidden') &&
+                    query('#qrcodelink').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.showViewButtons();
+                results.push(
+                    !query('#newbutton').classList.contains('hidden') &&
+                    !query('#clonebutton').classList.contains('hidden') &&
+                    !query('#rawtextbutton').classList.contains('hidden') &&
+                    !query('#qrcodelink').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.hideViewButtons();
+                results.push(
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#clonebutton').classList.contains('hidden') &&
+                    query('#rawtextbutton').classList.contains('hidden') &&
+                    query('#qrcodelink').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -70,7 +72,7 @@ describe('TopNav', function () {
             'displays & hides navigation elements for creating a document',
             function () { // eslint-disable-line complexity
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="newbutton" ' +
                     'type="button" class="hidden">New</button></li><li><a ' +
                     'id="expiration" href="#" class="hidden">Expiration</a>' +
@@ -81,40 +83,39 @@ describe('TopNav', function () {
                     '<li id="attach" class="hidden">Attach a file</li><li>' +
                     '<a id="formatter" href="#" class="hidden">Format</a>' +
                     '</li><li><button id="sendbutton" type="button" ' +
-                    'class="hidden">Create</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $('#sendbutton').hasClass('hidden') &&
-                    $('#expiration').hasClass('hidden') &&
-                    $('#formatter').hasClass('hidden') &&
-                    $('#burnafterreadingoption').hasClass('hidden') &&
-                    $('#opendiscussionoption').hasClass('hidden') &&
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#password').hasClass('hidden') &&
-                    $('#attach').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.showCreateButtons();
-                results.push(
-                    !$('#sendbutton').hasClass('hidden') &&
-                    !$('#expiration').hasClass('hidden') &&
-                    !$('#formatter').hasClass('hidden') &&
-                    !$('#burnafterreadingoption').hasClass('hidden') &&
-                    !$('#opendiscussionoption').hasClass('hidden') &&
-                    !$('#newbutton').hasClass('hidden') &&
-                    !$('#password').hasClass('hidden') &&
-                    !$('#attach').hasClass('hidden')
-                );
-                $.PrivateBin.TopNav.hideCreateButtons();
-                results.push(
-                    $('#sendbutton').hasClass('hidden') &&
-                    $('#expiration').hasClass('hidden') &&
-                    $('#formatter').hasClass('hidden') &&
-                    $('#burnafterreadingoption').hasClass('hidden') &&
-                    $('#opendiscussionoption').hasClass('hidden') &&
-                    $('#newbutton').hasClass('hidden') &&
-                    $('#password').hasClass('hidden') &&
-                    $('#attach').hasClass('hidden')
+                    'class="hidden">Create</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                results.push(
+                    query('#sendbutton').classList.contains('hidden') &&
+                    query('#expiration').classList.contains('hidden') &&
+                    query('#formatter').classList.contains('hidden') &&
+                    query('#burnafterreadingoption').classList.contains('hidden') &&
+                    query('#opendiscussionoption').classList.contains('hidden') &&
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#password').classList.contains('hidden') &&
+                    query('#attach').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.showCreateButtons();
+                results.push(
+                    !query('#sendbutton').classList.contains('hidden') &&
+                    !query('#expiration').classList.contains('hidden') &&
+                    !query('#formatter').classList.contains('hidden') &&
+                    !query('#burnafterreadingoption').classList.contains('hidden') &&
+                    !query('#opendiscussionoption').classList.contains('hidden') &&
+                    !query('#newbutton').classList.contains('hidden') &&
+                    !query('#password').classList.contains('hidden') &&
+                    !query('#attach').classList.contains('hidden')
+                );
+                PrivateBin.TopNav.hideCreateButtons();
+                results.push(
+                    query('#sendbutton').classList.contains('hidden') &&
+                    query('#expiration').classList.contains('hidden') &&
+                    query('#formatter').classList.contains('hidden') &&
+                    query('#burnafterreadingoption').classList.contains('hidden') &&
+                    query('#opendiscussionoption').classList.contains('hidden') &&
+                    query('#newbutton').classList.contains('hidden') &&
+                    query('#password').classList.contains('hidden') &&
+                    query('#attach').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -135,17 +136,16 @@ describe('TopNav', function () {
             'displays the button for creating a document',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="newbutton" type=' +
-                    '"button" class="hidden">New</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"button" class="hidden">New</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    $('#newbutton').hasClass('hidden')
+                    query('#newbutton').classList.contains('hidden')
                 );
-                $.PrivateBin.TopNav.showNewPasteButton();
+                PrivateBin.TopNav.showNewPasteButton();
                 results.push(
-                    !$('#newbutton').hasClass('hidden')
+                    !query('#newbutton').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -166,19 +166,18 @@ describe('TopNav', function () {
             'hides the button for cloning a document',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button id="clonebutton" ' +
                     'type="button" class="btn btn-warning navbar-btn">' +
                     '<span class="glyphicon glyphicon-duplicate" aria-hidden=' +
-                    '"true"></span> Clone</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"true"></span> Clone</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$('#clonebutton').hasClass('hidden')
+                    !query('#clonebutton').classList.contains('hidden')
                 );
-                $.PrivateBin.TopNav.hideCloneButton();
+                PrivateBin.TopNav.hideCloneButton();
                 results.push(
-                    $('#clonebutton').hasClass('hidden')
+                    query('#clonebutton').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -199,20 +198,19 @@ describe('TopNav', function () {
             'hides the raw text button',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li><button ' +
                     'id="rawtextbutton" type="button" class="btn ' +
                     'btn-warning navbar-btn"><span class="glyphicon ' +
                     'glyphicon-text-background" aria-hidden="true"></span> ' +
-                    'Raw text</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Raw text</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$('#rawtextbutton').hasClass('hidden')
+                    !query('#rawtextbutton').classList.contains('hidden')
                 );
-                $.PrivateBin.TopNav.hideRawButton();
+                PrivateBin.TopNav.hideRawButton();
                 results.push(
-                    $('#rawtextbutton').hasClass('hidden')
+                    query('#rawtextbutton').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -233,7 +231,7 @@ describe('TopNav', function () {
             'hides the file attachment selection button',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -242,15 +240,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$('#filewrap').hasClass('hidden')
+                    !query('#filewrap').classList.contains('hidden')
                 );
-                $.PrivateBin.TopNav.hideFileSelector();
+                PrivateBin.TopNav.hideFileSelector();
                 results.push(
-                    $('#filewrap').hasClass('hidden')
+                    query('#filewrap').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -271,7 +268,7 @@ describe('TopNav', function () {
             'display the custom file attachment',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -280,15 +277,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    $('#customattachment').hasClass('hidden')
+                    query('#customattachment').classList.contains('hidden')
                 );
-                $.PrivateBin.TopNav.showCustomAttachment();
+                PrivateBin.TopNav.showCustomAttachment();
                 results.push(
-                    !$('#customattachment').hasClass('hidden')
+                    !query('#customattachment').classList.contains('hidden')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -308,9 +304,8 @@ describe('TopNav', function () {
         it(
             'collapses the navigation when displayed on a small screen',
             function () {
-                const clean = jsdom();
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div class="navbar-header"><button type="button" ' +
                     'class="navbar-toggle collapsed" data-toggle="collapse" ' +
                     'data-target="#navbar" aria-expanded="false" aria-controls' +
@@ -318,34 +313,33 @@ describe('TopNav', function () {
                     'navbar-brand" href=""><img alt="PrivateBin" ' +
                     'src="img/icon.svg" width="38" /></a></div><div ' +
                     'id="navbar"><ul><li><button id="newbutton" type=' +
-                    '"button" class="hidden">New</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '"button" class="hidden">New</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') !== 'true'
+                    query('.navbar-toggle').classList.contains('collapsed') &&
+                    query('#navbar').getAttribute('aria-expanded') !== 'true'
                 );
-                $.PrivateBin.TopNav.collapseBar();
+                PrivateBin.TopNav.collapseBar();
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') !== 'true'
+                    query('.navbar-toggle').classList.contains('collapsed') &&
+                    query('#navbar').getAttribute('aria-expanded') !== 'true'
                 );
                 /*
                 with the upgrade for bootstrap-3.3.7.js to bootstrap-3.4.1.js
                 the mobile interface detection changed to check if the
                 ontouchstart event exists, which broke this section of the test
-                $('.navbar-toggle').trigger('click');
+                const toggleBtn = document.querySelector('.navbar-toggle');
+                toggleBtn.dispatchEvent(new MouseEvent('click'));
                 results.push(
-                    !$('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') == 'true'
+                    !toggleBtn.classList.contains('collapsed') &&
+                    document.getElementById('navbar').getAttribute('aria-expanded') == 'true'
                 );
-                $.PrivateBin.TopNav.collapseBar();
+                PrivateBin.TopNav.collapseBar();
                 results.push(
-                    $('.navbar-toggle').hasClass('collapsed') &&
-                    $('#navbar').attr('aria-expanded') == 'false'
+                    document.querySelector('.navbar-toggle').classList.contains('collapsed') &&
+                    document.getElementById('navbar').getAttribute('aria-expanded') == 'false'
                 );
                 */
-                clean();
                 const result = results.every(element => element);
                 if (!result) {
                     console.log(results);
@@ -364,36 +358,35 @@ describe('TopNav', function () {
             'reset inputs to defaults (options off)',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $('#burnafterreading').attr('checked', 'checked');
-                $('#opendiscussion').attr('checked', 'checked');
+                query('#burnafterreading').checked = true;
+                query('#opendiscussion').checked = true;
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -408,35 +401,35 @@ describe('TopNav', function () {
             'reset inputs to defaults (burnafterreading on)',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" checked="checked" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $('#burnafterreading').removeAttr('checked');
+                query('#burnafterreading').checked = false;
+                query('#burnafterreading').removeAttribute('checked');
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -451,36 +444,37 @@ describe('TopNav', function () {
             'reset inputs to defaults (opendiscussion on)',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
                     'Burn after reading</label></li><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" checked="checked" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $('#opendiscussion').removeAttr('checked');
-                $('#burnafterreading').prop('checked', true);
+                query('#opendiscussion').checked = false;
+                query('#opendiscussion').removeAttribute('checked');
+                query('#burnafterreading').checked = true;
+                query('#burnafterreading').setAttribute('checked', 'checked');
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $.PrivateBin.TopNav.resetInput();
+                PrivateBin.TopNav.resetInput();
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -500,13 +494,12 @@ describe('TopNav', function () {
         it(
             'returns the currently selected expiration date',
             function () {
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<select id="pasteExpiration" name="pasteExpiration">' +
                     '<option value="1day">1 day</option>' +
-                    '<option value="never">Never</option></select>'
-                );
-                $.PrivateBin.TopNav.init();
-                assert.strictEqual($.PrivateBin.TopNav.getExpiration(), '1day');
+                    '<option value="never">Never</option></select>';
+                PrivateBin.TopNav.init();
+                assert.strictEqual(PrivateBin.TopNav.getExpiration(), '1day');
                 cleanup();
             }
         );
@@ -558,7 +551,7 @@ describe('TopNav', function () {
             'returns the selected files',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -567,17 +560,16 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    $.PrivateBin.TopNav.getFileList() === null
+                    PrivateBin.TopNav.getFileList() === null
                 );
-                addFileList($('#file')[0], [
+                addFileList(query('#file'), [
                     '../img/logo.svg',
                     '../img/busy.gif'
                 ]);
-                const files = $.PrivateBin.TopNav.getFileList();
+                const files = PrivateBin.TopNav.getFileList();
                 results.push(
                     files[0].name === 'logo.svg' &&
                     files[1].name === 'busy.gif'
@@ -601,23 +593,23 @@ describe('TopNav', function () {
             'returns if the burn-after-reading checkbox was ticked',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="burnafterreadingoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="burnafterreading" name="burnafterreading" /> ' +
-                    'Burn after reading</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Burn after reading</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
-                $('#burnafterreading').attr('checked', 'checked');
+                query('#burnafterreading').checked = true;
                 results.push(
-                    $.PrivateBin.TopNav.getBurnAfterReading()
+                    PrivateBin.TopNav.getBurnAfterReading()
                 );
-                $('#burnafterreading').removeAttr('checked');
+                query('#burnafterreading').checked = false;
+                query('#burnafterreading').removeAttribute('checked');
                 results.push(
-                    !$.PrivateBin.TopNav.getBurnAfterReading()
+                    !PrivateBin.TopNav.getBurnAfterReading()
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -638,23 +630,23 @@ describe('TopNav', function () {
             'returns if the open-discussion checkbox was ticked',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="opendiscussionoption" ' +
                     'class="hidden"><label><input type="checkbox" ' +
                     'id="opendiscussion" name="opendiscussion" /> ' +
-                    'Open discussion</label></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    'Open discussion</label></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $('#opendiscussion').attr('checked', 'checked');
+                query('#opendiscussion').checked = true;
                 results.push(
-                    $.PrivateBin.TopNav.getOpenDiscussion()
+                    PrivateBin.TopNav.getOpenDiscussion()
                 );
-                $('#opendiscussion').removeAttr('checked');
+                query('#opendiscussion').checked = false;
+                query('#opendiscussion').removeAttribute('checked');
                 results.push(
-                    !$.PrivateBin.TopNav.getOpenDiscussion()
+                    !PrivateBin.TopNav.getOpenDiscussion()
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -668,41 +660,41 @@ describe('TopNav', function () {
 
     describe('getPassword', function () {
         before(function () {
-            cleanup();
+            globalThis.cleanup();
         });
 
-        jsc.property(
-            'returns the contents of the password input',
-            'string',
-            function (password) {
-                password = password.replace(/\r+|\n+/g, '');
-                let results = [];
-                $('body').html(
-                    '<nav><div id="navbar"><ul><li><div id="password" ' +
-                    'class="navbar-form hidden"><input type="password" ' +
-                    'id="passwordinput" placeholder="Password (recommended)" ' +
-                    'class="form-control" size="23" /></div></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === ''
-                );
-                $('#passwordinput').val(password);
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === password
-                );
-                $('#passwordinput').val('');
-                results.push(
-                    $.PrivateBin.TopNav.getPassword() === ''
-                );
-                cleanup();
-                const result = results.every(element => element);
-                if (!result) {
-                    console.log(results);
+        it('returns the contents of the password input', () => {
+            fc.assert(fc.property(
+                fc.string(),
+                function (password) {
+                    password = password.replace(/\r+|\n+/g, '');
+                    let results = [];
+                    document.documentElement.innerHTML =
+                        '<nav><div id="navbar"><ul><li><div id="password" ' +
+                        'class="navbar-form hidden"><input type="password" ' +
+                        'id="passwordinput" placeholder="Password (recommended)" ' +
+                        'class="form-control" size="23" /></div></li></ul></div></nav>';
+                    PrivateBin.TopNav.init();
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === ''
+                    );
+                    query('#passwordinput').value = password;
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === password
+                    );
+                    query('#passwordinput').value = '';
+                    results.push(
+                        PrivateBin.TopNav.getPassword() === ''
+                    );
+                    globalThis.cleanup();
+                    const result = results.every(element => element);
+                    if (!result) {
+                        console.log(results);
+                    }
+                    return result;
                 }
-                return result;
-            }
-        );
+            ));
+        });
     });
 
     describe('getCustomAttachment', function () {
@@ -714,7 +706,7 @@ describe('TopNav', function () {
             'returns the custom attachment element',
             function () {
                 let results = [];
-                $('body').html(
+                document.documentElement.innerHTML =
                     '<nav><div id="navbar"><ul><li id="attach" class="hidden ' +
                     'dropdown"><a href="#" class="dropdown-toggle" data-' +
                     'toggle="dropdown" role="button" aria-haspopup="true" ' +
@@ -723,15 +715,14 @@ describe('TopNav', function () {
                     '<div><input type="file" id="file" name="file" /></div>' +
                     '</li><li id="customattachment" class="hidden"></li><li>' +
                     '<a id="fileremovebutton"  href="#">Remove attachment</a>' +
-                    '</li></ul></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
+                    '</li></ul></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
                 results.push(
-                    !$.PrivateBin.TopNav.getCustomAttachment().hasClass('test')
+                    !PrivateBin.TopNav.getCustomAttachment().classList.contains('test')
                 );
-                $('#customattachment').addClass('test');
+                query('#customattachment').classList.add('test');
                 results.push(
-                    $.PrivateBin.TopNav.getCustomAttachment().hasClass('test')
+                    PrivateBin.TopNav.getCustomAttachment().classList.contains('test')
                 );
                 cleanup();
                 const result = results.every(element => element);
@@ -753,7 +744,7 @@ describe('TopNav', function () {
             function () {
                 // Insert any setup code needed for the hideAllButtons function
                 // Example: Initialize the DOM elements required for testing
-                $('body').html(
+                document.body.innerHTML =
                     '<nav class="navbar navbar-inverse navbar-static-top">' +
                     '<div id="navbar" class="navbar-collapse collapse"><ul ' +
                     'class="nav navbar-nav"><li><button id="newbutton" ' +
@@ -770,15 +761,14 @@ describe('TopNav', function () {
                     'data-toggle="modal" data-target="#qrcodemodal" ' +
                     'class="hidden btn btn-warning navbar-btn"><span ' +
                     'class="glyphicon glyphicon-qrcode" aria-hidden="true">' +
-                    '</span> QR code</button></li></ul></div></nav>'
-                );
-                $.PrivateBin.TopNav.init();
-                $.PrivateBin.TopNav.hideAllButtons();
-
-                assert.ok($('#newbutton').hasClass('hidden'));
-                assert.ok($('#clonebutton').hasClass('hidden'));
-                assert.ok($('#rawtextbutton').hasClass('hidden'));
-                assert.ok($('#qrcodelink').hasClass('hidden'));
+                    '</span> QR code</button></li></ul></div></nav>';
+                PrivateBin.TopNav.init();
+                PrivateBin.TopNav.hideAllButtons();
+
+                assert.ok(query('#newbutton').classList.contains('hidden'));
+                assert.ok(query('#clonebutton').classList.contains('hidden'));
+                assert.ok(query('#rawtextbutton').classList.contains('hidden'));
+                assert.ok(query('#qrcodelink').classList.contains('hidden'));
                 cleanup();
             }
         );
@@ -793,14 +783,17 @@ describe('TopNav', function () {
         it(
             'displays raw text view correctly',
             function () {
-                const clean = jsdom('', {url: 'https://privatebin.net/?0123456789abcdef#1'});
-                $('body').html('<button id="rawtextbutton"></button>');
+                const clean = globalThis.cleanup('', {url: 'https://privatebin.net/?0123456789abcdef#1'});
+                document.documentElement.innerHTML = `
+                <li id="loadingindicator" class="hidden"></li>
+                <button id="rawtextbutton"></button>`;
                 const sample = 'example';
-                $.PrivateBin.PasteViewer.setText(sample);
-                $.PrivateBin.Helper.reset();
-                $.PrivateBin.TopNav.init();
-                $('#rawtextbutton').click();
-                assert.strictEqual($('pre').text(), sample);
+                PrivateBin.Alert.init(); // required because of locading indiator being used
+                PrivateBin.TopNav.init();
+                PrivateBin.PasteViewer.setText(sample);
+                PrivateBin.Helper.reset();
+                query('#rawtextbutton').click();
+                assert.strictEqual(document.querySelector('pre').textContent, sample);
                 clean();
             }
         );

+ 51 - 77
js/test/UiHelper.js

@@ -1,5 +1,6 @@
 'use strict';
-var common = require('../common');
+const common = require('../common');
+const fc = require('fast-check');
 
 describe('UiHelper', function () {
     // TODO: As per https://github.com/tmpvar/jsdom/issues/1565 there is no navigation support in jsdom, yet.
@@ -7,43 +8,45 @@ describe('UiHelper', function () {
     describe('historyChange', function () {
         this.timeout(30000);
         beforeEach(function () {
-            $.PrivateBin.Helper.reset();
+            PrivateBin.Helper.reset();
             cleanup();
         });
 
-        jsc.property(
-            'redirects to home, when the state is null',
-            common.jscUrl(false, false),
-            function (url) {
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: expected});
+        it('redirects to home, when the state is null', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false, false),
+                function (url) {
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: expected});
 
-                $.PrivateBin.UiHelper.mockHistoryChange();
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
+                    PrivateBin.UiHelper.mockHistoryChange();
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
 
-        jsc.property(
-            'does not redirect to home, when a new document is created',
-            common.jscUrl(false),
-            jsc.nearray(common.jscBase64String()),
-            function (url, fragment) {
-                url.fragment = fragment.join('');
-                const expected = common.urlToString(url),
-                    clean = jsdom('', {url: expected});
+        it('does not redirect to home, when a new document is created', () => {
+            fc.assert(fc.property(
+                common.fcUrl(false),
+                fc.array(common.fcBase64String(), {minLength: 1}),
+                function (url, fragment) {
+                    url.fragment = fragment.join('');
+                    const expected = common.urlToString(url),
+                        clean = globalThis.cleanup('', {url: expected});
 
-                $.PrivateBin.UiHelper.mockHistoryChange([
-                    {type: 'newpaste'}, '', expected
-                ]);
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
+                    PrivateBin.UiHelper.mockHistoryChange([
+                        {type: 'newpaste'}, '', expected
+                    ]);
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
+        });
     });
 
     describe('reloadHome', function () {
@@ -51,56 +54,27 @@ describe('UiHelper', function () {
         /*
         this.timeout(30000);
         before(function () {
-            $.PrivateBin.Helper.reset();
+            PrivateBin.Helper.reset();
         });
 
-        jsc.property(
-            'redirects to home',
-            common.jscUrl(),
-            function (url) {
-                const clean = jsdom('', {url: common.urlToString(url)});
-                delete(url.query);
-                delete(url.fragment);
-                const expected = common.urlToString(url);
-
-                $.PrivateBin.UiHelper.reloadHome();
-                $.PrivateBin.Helper.reset();
-                var result = window.location.href;
-                clean();
-                return expected === result;
-            }
-        );
-        */
-    });
+        it('redirects to home', () => {
+            fc.assert(fc.property(
+                common.fcUrl(),
+                function (url) {
+                    const clean = globalThis.cleanup('', {url: common.urlToString(url)});
+                    delete(url.query);
+                    delete(url.fragment);
+                    const expected = common.urlToString(url);
 
-    describe('isVisible', function () {
-        // TODO As per https://github.com/tmpvar/jsdom/issues/1048 there is no layout support in jsdom, yet.
-        // once it is supported or a workaround is found, uncomment the section below
-        /*
-        before(function () {
-            $.PrivateBin.Helper.reset();
+                    PrivateBin.UiHelper.reloadHome();
+                    PrivateBin.Helper.reset();
+                    var result = window.location.href;
+                    clean();
+                    return expected === result;
+                }
+            ));
         });
-
-        jsc.property(
-            'detect visible elements',
-            jsc.nearray(common.jscAlnumString()),
-            jsc.nearray(common.jscA2zString()),
-            function (id, element) {
-                id = id.join('');
-                element = element.join('');
-                var clean = jsdom(
-                    '<' + element + ' id="' + id + '"></' + element + '>'
-                );
-                var result = $.PrivateBin.UiHelper.isVisible($('#' + id));
-                clean();
-                return result;
-            }
-        );
         */
     });
-
-    describe('scrollTo', function () {
-        // TODO Did not find a way to test that, see isVisible test above
-    });
 });
 

+ 50 - 44
js/test/emailTemplateTest.js

@@ -3,7 +3,7 @@ require('../common');
 
 // DOM builder that mirrors bootstrap5.php navbar
 function buildEmailDomNoShortUrl() {
-    $('body').html(
+    document.documentElement.innerHTML =
         // TopNav expects initially hidden #emaillink BUTTON.
         '<nav><div id="navbar"><ul>' +
           '<li>' +
@@ -15,14 +15,18 @@ function buildEmailDomNoShortUrl() {
             '<button id="emaillink" type="button" class="hidden btn btn-secondary">Email</button>' +
           '</li>' +
         '</ul></div></nav>' +
-        '<input id="burnafterreadingoption" type="checkbox">'
-    );
+        '<input id="burnafterreadingoption" type="checkbox">' +
+        // include dummy email confirm modal for sendEmail
+        '<div id="emailconfirmmodal" class="hidden">' +
+            '<div id="emailconfirm-timezone-current"></div>' +
+            '<div id="emailconfirm-timezone-utc"></div>' +
+        '</div>'
 }
 
 // DOM builder that adds the shortener result block
 function buildEmailDomWithShortUrl() {
     buildEmailDomNoShortUrl();
-    $('body').html(
+    document.documentElement.innerHTML =
         // TopNav expectsinitially hidden #emaillink BUTTON.
         '<nav><div id="navbar"><ul>' +
           '<li>' +
@@ -38,41 +42,41 @@ function buildEmailDomWithShortUrl() {
         '<div id="pastelink">Your document is ' +
           '<a id="pasteurl" href="https://short.example/xYz">https://short.example/xYz</a> ' +
           '<span id="copyhint">(Hit <kbd>Ctrl</kbd>+<kbd>c</kbd> to copy)</span>' +
+        '</div>' +
+        // add a minimal email confirmation modal so sendEmail does not crash
+        '<div id="emailconfirmmodal" class="hidden">' +
+            '<div id="emailconfirm-timezone-current"></div>' +
+            '<div id="emailconfirm-timezone-utc"></div>' +
         '</div>'
-    );
 }
 
 
-function stubWinOpen($element) {
-    const win = $element[0].ownerDocument.defaultView;
-
-    // Some helpers in privatebin.js expect a global document.
-    global.document = win.document;
-
+function makeWindowOpenMock() {
+    const originalOpen = window.open;
     let openedUrl = null;
-    const origOpen = win.open;
+    let mockRestoreFn = null;
 
-    // Prefer simple assignment; if blocked, fall back to defineProperty.
-    try {
-        win.open = function (url) {
+    if (typeof jest !== 'undefined' && typeof jest.spyOn === 'function') {
+        const spy = jest.spyOn(window, 'open').mockImplementation((url) => {
             openedUrl = url;
             return {};
-        };
-    } catch (e) {
-        Object.defineProperty(win, 'open', {
-            value: function (url) {
-                openedUrl = url;
-                return {};
-            },
-            configurable: true,
-            writable: true
         });
+        mockRestoreFn = () => spy.mockRestore();
+    } else {
+        window.open = function (url) {
+            openedUrl = url;
+            return {};
+        };
+        mockRestoreFn = () => { window.open = originalOpen; };
     }
 
     return {
         getUrl: () => openedUrl,
-        restore: () => { try { win.open = origOpen; } catch (e) { /* suppress exception in restore */ } },
-        win
+        restore: () => {
+            if (mockRestoreFn) {
+                mockRestoreFn();
+            }
+        }
     };
 }
 
@@ -84,21 +88,23 @@ function extractMailtoBody(mailtoUrl) {
 }
 
 describe('Email - mail body content (short URL vs. fallback)', function () {
-    before(function () {
+    beforeEach(function () {
         cleanup(); // provided by common
     });
 
     it('Uses the short URL when #pasteurl is present and never includes "undefined"', function () {
-        buildEmailDomWithShortUrl();               // with #pastelink/#pasteurl
-        $.PrivateBin.TopNav.init();
-        $.PrivateBin.TopNav.showEmailButton(0);
+        buildEmailDomWithShortUrl(); // with #pastelink/#pasteurl
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
 
-        const $emailBtn = $('#emaillink');
-        assert.ok(!$emailBtn.hasClass('hidden'), '#emaillink should be visible after showEmailButton');
+        const emailBtn = document.getElementById('emaillink');
+        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
 
-        const { getUrl, restore } = stubWinOpen($emailBtn);
+        const { getUrl, restore } = makeWindowOpenMock();
         try {
-            $emailBtn.trigger('click');
+            emailBtn.click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
             const openedUrl = getUrl();
             assert.ok(openedUrl, 'window.open should have been called');
 
@@ -107,30 +113,30 @@ describe('Email - mail body content (short URL vs. fallback)', function () {
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
         } finally {
             restore();
-            cleanup();
         }
     });
 
     it('Falls back to window.location.href when #pasteurl is absent and never includes "undefined"', function () {
-        buildEmailDomNoShortUrl();      // No #pasteurl
-        $.PrivateBin.TopNav.init();
-        $.PrivateBin.TopNav.showEmailButton(0);
+        buildEmailDomNoShortUrl(); // No #pasteurl
+        PrivateBin.TopNav.init();
+        PrivateBin.TopNav.showEmailButton(0);
 
-        const $emailBtn = $('#emaillink');
-        assert.ok(!$emailBtn.hasClass('hidden'), '#emaillink should be visible after showEmailButton');
+        const emailBtn = document.getElementById('emaillink');
+        assert.ok(!emailBtn.classList.contains('hidden'), '#emaillink should be visible after showEmailButton');
 
-        const { getUrl, restore, win } = stubWinOpen($emailBtn);
+        const { getUrl, restore } = makeWindowOpenMock();
         try {
-            $emailBtn.trigger('click');
+            emailBtn.click();
+            document.getElementById('emailconfirm-timezone-current').click();
+
             const openedUrl = getUrl();
             assert.ok(openedUrl, 'window.open should have been called');
 
             const body = extractMailtoBody(openedUrl);
-            assert.match(body, new RegExp(win.location.href), 'email body should include the fallback page URL');
+            assert.match(body, new RegExp(window.location.href), 'email body should include the fallback page URL');
             assert.doesNotMatch(body, /undefined/, 'email body must not contain "undefined"');
         } finally {
             restore();
-            cleanup();
         }
     });
 });

+ 2 - 4
lib/Configuration.php

@@ -115,14 +115,12 @@ class Configuration
         // update this array when adding/changing/removing js files
         'sri' => [
             'js/base-x-5.0.1.js'     => 'sha512-FmhlnjIxQyxkkxQmzf0l6IRGsGbgyCdgqPxypFsEtHMF1naRqaLLo6mcyN5rEaT16nKx1PeJ4g7+07D6gnk/Tg==',
-            'js/bootstrap-3.4.1.js'  => 'sha512-oBTprMeNEKCnqfuqKd6sbvFzmFQtlXS3e0C/RGFV0hD6QzhHV+ODfaQbAlmY6/q0ubbwlAM/nCJjkrgA3waLzg==',
             'js/bootstrap-5.3.8.js'  => 'sha512-BkZvJ5rZ3zbDCod5seWHpRGg+PRd6ZgE8Nua/OMtcxqm8Wtg0PqwhUUXK5bqvl3oclMt5O+3zjRVX0L+L2j7fA==',
             'js/dark-mode-switch.js' => 'sha512-BhY7dNU14aDN5L+muoUmA66x0CkYUWkQT0nxhKBLP/o2d7jE025+dvWJa4OiYffBGEFgmhrD/Sp+QMkxGMTz2g==',
-            'js/jquery-3.7.1.js'     => 'sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==',
             'js/kjua-0.10.0.js'      => 'sha512-BYj4xggowR7QD150VLSTRlzH62YPfhpIM+b/1EUEr7RQpdWAGKulxWnOvjFx1FUlba4m6ihpNYuQab51H6XlYg==',
-            'js/legacy.js'           => 'sha512-RQEo1hxpNc37i+jz/D9/JiAZhG8GFx3+SNxjYnI7jUgirDIqrCSj6QPAAZeaidditcWzsJ3jxfEj5lVm7ZwTRQ==',
+            'js/legacy.js'           => 'sha512-pRofxsrf5UItjiP22Dcjh3FAcBjF/n7h8U9/W5xqJk17U0N2U1oajhXypq/omo9jhwS1iVGOhWrRepoPeFns+w==',
             'js/prettify.js'         => 'sha512-puO0Ogy++IoA2Pb9IjSxV1n4+kQkKXYAEUtVzfZpQepyDPyXk8hokiYDS7ybMogYlyyEIwMLpZqVhCkARQWLMg==',
-            'js/privatebin.js'       => 'sha512-g58rPgrKnvxBDnT6lzYazdEfgY3XhRh7Yt7i1jkJw4oSvN0k/VPZ4zBSL/dCGfXGIOHqlqmX8DO5UFv2XYqsXg==',
+            'js/privatebin.js'       => 'sha512-ah/QdETig/ovH1QPl9Ne+T3VvaP7Bj4Rd9Or9wqQgo5BHge9o02MxAlRGXUZpKviy/27OSzHgz2PwpnH/B2RZg==',
             'js/purify-3.4.12.js'    => 'sha512-Akf6HnAJZm0sWWWI4gp2GYff0NDnHUB02XJE5S7Hdq/Z5xtMjkuFacsDA8ZtViv1gi+onBxMhEMIaGyQeGxBng==',
             'js/showdown-2.1.0.js'   => 'sha512-WYXZgkTR0u/Y9SVIA4nTTOih0kXMEd8RRV6MLFdL6YU8ymhR528NLlYQt1nlJQbYz4EW+ZsS0fx1awhiQJme1Q==',
             'js/zlib-1.3.2.js'       => 'sha512-RAhJgxg9siMIA8ky4c10Rc2zUgnK80olHB8Tt1IOYWY4Eh1WmrviQkDn+sgBlb38ZHq3tzufGC41kP360gmosQ==',

+ 0 - 714
tpl/bootstrap.php

@@ -1,714 +0,0 @@
-<?php declare(strict_types=1);
-use PrivateBin\I18n;
-$isCpct = substr($template, 9, 8) === '-compact';
-$isDark = substr($template, 9, 5) === '-dark';
-$isPage = substr($template, -5) === '-page';
-?><!DOCTYPE html>
-<html lang="<?php echo I18n::getLanguage(); ?>"<?php echo I18n::isRtl() ? ' dir="rtl"' : ''; ?>>
-	<head>
-		<meta charset="utf-8" />
-		<meta http-equiv="Content-Security-Policy" content="<?php echo I18n::encode($CSPHEADER); ?>">
-		<meta http-equiv="X-UA-Compatible" content="IE=edge">
-		<meta name="viewport" content="width=device-width, initial-scale=1">
-		<meta name="robots" content="noindex" />
-		<meta name="google" content="notranslate">
-		<title><?php echo I18n::_($NAME); ?></title>
-<?php
-if (!$isDark) :
-?>
-		<link type="text/css" rel="stylesheet" href="css/bootstrap/bootstrap-3.4.1.css" />
-<?php
-endif;
-?>
-		<link type="text/css" rel="stylesheet" href="css/bootstrap/bootstrap-theme-3.4.1.css" />
-<?php
-if ($isDark) :
-?>
-		<link type="text/css" rel="stylesheet" href="css/bootstrap/darkstrap-0.9.3.css" />
-<?php
-endif;
-?>
-		<link type="text/css" rel="stylesheet" href="css/bootstrap/privatebin.css?<?php echo rawurlencode($VERSION); ?>" />
-<?php
-if ($SYNTAXHIGHLIGHTING) :
-?>
-		<link type="text/css" rel="stylesheet" href="css/prettify/prettify.css?<?php echo rawurlencode($VERSION); ?>" />
-<?php
-    if (!empty($SYNTAXHIGHLIGHTINGTHEME)) :
-?>
-		<link type="text/css" rel="stylesheet" href="css/prettify/<?php echo rawurlencode($SYNTAXHIGHLIGHTINGTHEME); ?>.css?<?php echo rawurlencode($VERSION); ?>" />
-<?php
-    endif;
-endif;
-?>
-		<noscript><link type="text/css" rel="stylesheet" href="css/noscript.css" /></noscript>
-		<?php $this->_linkTag('js/zlib-1.3.2.js'); ?>
-		<?php $this->_scriptTag('js/jquery-3.7.1.js', 'defer'); ?>
-<?php
-if ($QRCODE) :
-?>
-		<?php $this->_scriptTag('js/kjua-0.10.0.js', 'defer'); ?>
-<?php
-endif;
-?>
-		<?php $this->_scriptTag('js/zlib.js', 'defer'); ?>
-		<?php $this->_scriptTag('js/base-x-5.0.1.js', 'defer'); ?>
-		<?php $this->_scriptTag('js/bootstrap-3.4.1.js', 'defer'); ?>
-<?php
-if ($SYNTAXHIGHLIGHTING) :
-?>
-		<?php $this->_scriptTag('js/prettify.js', 'defer'); ?>
-<?php
-endif;
-if ($MARKDOWN) :
-?>
-		<?php $this->_scriptTag('js/showdown-2.1.0.js', 'defer'); ?>
-<?php
-endif;
-?>
-		<?php $this->_scriptTag('js/purify-3.4.12.js', 'defer'); ?>
-		<?php $this->_scriptTag('js/legacy.js', 'defer'); ?>
-		<?php $this->_scriptTag('js/privatebin.js', 'defer'); ?>
-		<!-- icon -->
-		<link rel="apple-touch-icon" href="<?php echo I18n::encode($BASEPATH); ?>img/apple-touch-icon.png" sizes="180x180" />
-		<link rel="icon" type="image/png" href="img/favicon-32x32.png" sizes="32x32" />
-		<link rel="icon" type="image/png" href="img/favicon-16x16.png" sizes="16x16" />
-		<link rel="manifest" href="manifest.json?<?php echo rawurlencode($VERSION); ?>" />
-		<link rel="mask-icon" href="img/safari-pinned-tab.svg" color="#ffcc00" />
-		<link rel="shortcut icon" href="img/favicon.ico">
-		<meta name="msapplication-config" content="browserconfig.xml">
-		<meta name="theme-color" content="#ffe57e" />
-		<!-- Twitter/social media cards -->
-		<meta name="twitter:card" content="summary" />
-		<meta name="twitter:title" content="<?php echo I18n::_('Encrypted note on %s', I18n::_($NAME)) ?>" />
-		<meta name="twitter:description" content="<?php echo I18n::_('Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.') ?>" />
-		<meta name="twitter:image" content="<?php echo I18n::encode($BASEPATH); ?>img/apple-touch-icon.png" />
-		<meta property="og:title" content="<?php echo I18n::_($NAME); ?>" />
-		<meta property="og:site_name" content="<?php echo I18n::_($NAME); ?>" />
-		<meta property="og:description" content="<?php echo I18n::_('Visit this link to see the note. Giving the URL to anyone allows them to access the note, too.') ?>" />
-		<meta property="og:image" content="<?php echo I18n::encode($BASEPATH); ?>img/apple-touch-icon.png" />
-		<meta property="og:image:type" content="image/png" />
-		<meta property="og:image:width" content="180" />
-		<meta property="og:image:height" content="180" />
-	</head>
-	<body role="document" data-compression="<?php echo rawurlencode($COMPRESSION); ?>"<?php
-$class = [];
-if ($isCpct) {
-    $class[] = 'navbar-spacing';
-}
-if ($isDark) {
-    $class[] = 'dark-theme';
-}
-if (count($class)) {
-    echo ' class="', implode(' ', $class), '"';
-}
-?>>
-		<div id="passwordmodal" tabindex="-1" class="modal fade" role="dialog" aria-hidden="true">
-			<div class="modal-dialog" role="document">
-				<div class="modal-content">
-					<div class="modal-body">
-						<form id="passwordform" role="form">
-							<div class="form-group">
-								<label for="passworddecrypt"><span class="glyphicon glyphicon-eye-open"></span> <?php echo I18n::_('Please enter the password for this document:') ?></label>
-								<div class="input-group">
-									<input id="passworddecrypt" type="password" class="form-control input-password" placeholder="<?php echo I18n::_('Enter password') ?>" required="required">
-									<div class="input-group-addon toggle-password" type="button" title="<?php echo I18n::_('Show password'); ?>" aria-label="<?php echo I18n::_('Show password'); ?>">
-										<span class="glyphicon glyphicon-eye-open"></span>
-									</div>
-								</div>
-							</div>
-							<button type="submit" class="btn btn-success btn-block"><span class="glyphicon glyphicon-off"></span> <?php echo I18n::_('Decrypt') ?></button>
-						</form>
-					</div>
-				</div>
-			</div>
-		</div>
-		<div id="loadconfirmmodal" tabindex="-1" class="modal fade" role="dialog" aria-hidden="true">
-			<div class="modal-dialog" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<button type="button" class="close" data-dismiss="modal" aria-label="<?php echo I18n::_('Close') ?>"><span aria-hidden="true">&times;</span></button>
-						<h4 class="modal-title"><?php echo I18n::_('This secret message can only be displayed once. Would you like to see it now?') ?></h4>
-					</div>
-					<div class="modal-body text-center">
-						<button id="loadconfirm-open-now" type="button" class="btn btn-success" data-dismiss="modal"><span class="glyphicon glyphicon-download"></span> <?php echo I18n::_('Yes, see it') ?></button>
-					</div>
-				</div>
-			</div>
-		</div>
-<?php
-if ($QRCODE) :
-?>
-		<div id="qrcodemodal" tabindex="-1" class="modal fade" role="dialog" aria-hidden="true">
-			<div class="modal-dialog" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<button type="button" class="close" data-dismiss="modal" aria-label="<?php echo I18n::_('Close') ?>"><span aria-hidden="true">&times;</span></button>
-						<h4 class="modal-title"><?php echo I18n::_('QR code') ?></h4>
-					</div>
-					<div class="modal-body">
-						<div class="mx-auto" id="qrcode-display"></div>
-					</div>
-				</div>
-			</div>
-		</div>
-<?php
-endif;
-if ($EMAIL) :
-?>
-		<div id="emailconfirmmodal" tabindex="-1" class="modal fade" role="dialog" aria-hidden="true">
-			<div class="modal-dialog" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<button type="button" class="close" data-dismiss="modal" aria-label="<?php echo I18n::_('Close') ?>"><span aria-hidden="true">&times;</span></button>
-						<h4 class="modal-title"><?php echo I18n::_('Recipient may become aware of your timezone, convert time to UTC?') ?></h4>
-					</div>
-					<div class="modal-body row">
-						<div class="col-xs-12 col-md-6">
-							<button id="emailconfirm-timezone-current" type="button" class="btn btn-danger"><span class="glyphicon glyphicon-time"></span> <?php echo I18n::_('Use Current Timezone') ?></button>
-						</div>
-						<div class="col-xs-12 col-md-6 text-right">
-							<button id="emailconfirm-timezone-utc" type="button" class="btn btn-success"><span class="glyphicon glyphicon-globe"></span> <?php echo I18n::_('Convert To UTC') ?></button>
-						</div>
-					</div>
-				</div>
-			</div>
-		</div>
-<?php
-endif;
-?>
-		<nav class="navbar navbar-<?php echo $isDark ? 'inverse' : 'default'; ?> navbar-<?php echo $isCpct ? 'fixed' : 'static'; ?>-top"><?php
-if ($isCpct) :
-?><div class="container"><?php
-endif;
-?>
-			<div class="navbar-header">
-				<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
-					<span class="sr-only"><?php echo I18n::_('Toggle navigation'); ?></span>
-					<span class="icon-bar"></span>
-					<span class="icon-bar"></span>
-					<span class="icon-bar"></span>
-				</button>
-				<a class="reloadlink navbar-brand" href="">
-					<img alt="<?php echo I18n::_($NAME); ?>" src="img/icon.svg" width="38" />
-				</a>
-			</div>
-			<div id="navbar" class="navbar-collapse collapse">
-				<ul class="nav navbar-nav">
-					<li id="loadingindicator" class="navbar-text hidden">
-						<span class="glyphicon glyphicon-time" aria-hidden="true"></span>
-						<?php echo I18n::_('Loading…'), PHP_EOL; ?>
-					</li>
-					<li>
-						<button id="retrybutton" type="button" class="reloadlink hidden btn btn-<?php echo $isDark ? 'warning' : 'primary'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-repeat" aria-hidden="true"></span> <?php echo I18n::_('Retry'), PHP_EOL; ?>
-						</button>
-					</li>
-					<li>
-<?php
-if ($isPage) :
-?>
-						<button id="sendbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'primary'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> <?php echo I18n::_('Create'), PHP_EOL;
-else :
-?>
-						<button id="newbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-file" aria-hidden="true"></span> <?php echo I18n::_('New'), PHP_EOL;
-endif;
-?>
-						</button>
-						<button id="clonebutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> <?php echo I18n::_('Clone'), PHP_EOL; ?>
-						</button>
-						<button id="rawtextbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-text-background" aria-hidden="true"></span> <?php echo I18n::_('Raw text'), PHP_EOL; ?>
-						</button>
-						<button id="downloadtextbutton" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon glyphicon-download-alt" aria-hidden="true"></span> <?php echo I18n::_('Save document'), PHP_EOL; ?>
-						</button>
-<?php
-if ($EMAIL) :
-?>
-
-						<button id="emaillink" type="button" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-envelope" aria-hidden="true"></span> <?php echo I18n::_('Email'), PHP_EOL; ?>
-						</button>
-<?php
-endif;
-if ($QRCODE) :
-?>
-						<button id="qrcodelink" type="button" data-toggle="modal" data-target="#qrcodemodal" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-qrcode" aria-hidden="true"></span> <?php echo I18n::_('QR code'), PHP_EOL; ?>
-						</button>
-<?php
-endif;
-?>
-					</li>
-					<li class="dropdown">
-						<select id="pasteExpiration" name="pasteExpiration" class="hidden">
-<?php
-foreach ($EXPIRE as $key => $value) :
-?>
-							<option value="<?php echo $key; ?>"<?php
-    if ($key === $EXPIREDEFAULT) :
-?> selected="selected"<?php
-    endif;
-?>><?php echo $value; ?></option>
-<?php
-endforeach;
-?>
-						</select>
-						<a id="expiration" href="#" class="hidden dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><?php echo I18n::_('Expires'); ?>: <span id="pasteExpirationDisplay"><?php echo $EXPIRE[$EXPIREDEFAULT]; ?></span> <span class="caret"></span></a>
-						<ul class="dropdown-menu">
-<?php
-foreach ($EXPIRE as $key => $value) :
-?>
-							<li>
-								<a href="#" data-expiration="<?php echo $key; ?>">
-									<?php echo $value, PHP_EOL; ?>
-								</a>
-							</li>
-<?php
-endforeach;
-?>
-						</ul>
-					</li>
-<?php
-if ($isCpct) :
-?>
-					<li class="dropdown">
-						<a id="formatter" href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><?php echo I18n::_('Options'); ?> <span class="caret"></span></a>
-						<ul class="dropdown-menu">
-							<li id="burnafterreadingoption" class="checkbox hidden">
-								<label>
-									<input type="checkbox" id="burnafterreading" name="burnafterreading"<?php
-    if ($BURNAFTERREADINGSELECTED) :
-?> checked="checked"<?php
-    endif;
-?> />
-									<?php echo I18n::_('Burn after reading'), PHP_EOL; ?>
-								</label>
-							</li>
-<?php
-    if ($DISCUSSION) :
-?>
-							<li id="opendiscussionoption" class="checkbox hidden">
-								<label>
-									<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
-        if ($OPENDISCUSSION) :
-?> checked="checked"<?php
-        endif;
-?> />
-									<?php echo I18n::_('Open discussion'), PHP_EOL; ?>
-								</label>
-							</li>
-<?php
-    endif;
-?>
-							<li role="separator" class="divider"></li>
-							<li>
-								<div>
-									<?php echo I18n::_('Format'); ?>: <span id="pasteFormatterDisplay"><?php echo $FORMATTER[$FORMATTERDEFAULT]; ?></span> <span class="caret"></span>
-								</div>
-							</li>
-<?php
-    foreach ($FORMATTER as $key => $value) :
-?>
-							<li>
-								<a href="#" data-format="<?php echo $key; ?>">
-									<?php echo $value, PHP_EOL; ?>
-								</a>
-							</li>
-<?php
-    endforeach;
-?>
-						</ul>
-						<select id="pasteFormatter" name="pasteFormatter" class="hidden">
-<?php
-    foreach ($FORMATTER as $key => $value) :
-?>
-							<option value="<?php echo $key; ?>"<?php
-        if ($key === $FORMATTERDEFAULT) :
-?> selected="selected"<?php
-        endif;
-?>><?php echo $value; ?></option>
-<?php
-    endforeach;
-?>
-						</select>
-					</li>
-<?php
-else :
-?>
-					<li>
-						<div id="burnafterreadingoption" class="navbar-text checkbox hidden">
-							<label>
-								<input type="checkbox" id="burnafterreading" name="burnafterreading"<?php
-    if ($BURNAFTERREADINGSELECTED) :
-?> checked="checked"<?php
-    endif;
-?> />
-								<?php echo I18n::_('Burn after reading'), PHP_EOL; ?>
-							</label>
-						</div>
-					</li>
-<?php
-    if ($DISCUSSION) :
-?>
-					<li>
-						<div id="opendiscussionoption" class="navbar-text checkbox hidden">
-							<label>
-								<input type="checkbox" id="opendiscussion" name="opendiscussion"<?php
-        if ($OPENDISCUSSION) :
-?> checked="checked"<?php
-        endif;
-?> />
-								<?php echo I18n::_('Open discussion'), PHP_EOL; ?>
-							</label>
-						</div>
-					</li>
-<?php
-    endif;
-endif;
-if ($PASSWORD) :
-?>
-					<li>
-						<div id="password" class="navbar-form hidden">
-							<div class="input-group">
-								<input type="password" id="passwordinput" placeholder="<?php echo I18n::_('Password (recommended)'); ?>" class="form-control input-password" size="23" />
-								<div class="input-group-addon toggle-password" type="button" title="<?php echo I18n::_('Show password'); ?>" aria-label="<?php echo I18n::_('Show password'); ?>">
-									<span class="glyphicon glyphicon-eye-open"></span>
-								</div>
-							</div>
-						</div>
-					</li>
-<?php
-endif;
-if ($FILEUPLOAD) :
-?>
-					<li id="attach" class="hidden dropdown">
-						<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><?php echo I18n::_('Attach a file'); ?> <span class="caret"></span></a>
-						<ul class="dropdown-menu">
-							<li id="filewrap">
-								<div>
-									<input type="file" id="file" name="file" multiple />
-								</div>
-								<div id="dragAndDropFileName" class="dragAndDropFile"><?php echo I18n::_('alternatively drag & drop a file or paste an image from the clipboard'); ?></div>
-							</li>
-							<li id="customattachment" class="hidden"></li>
-							<li>
-								<a id="fileremovebutton"  href="#">
-									<?php echo I18n::_('Remove attachment'), PHP_EOL; ?>
-								</a>
-							</li>
-						</ul>
-					</li>
-<?php
-endif;
-if (!$isCpct) :
-?>
-					<li class="dropdown">
-						<select id="pasteFormatter" name="pasteFormatter" class="hidden">
-<?php
-    foreach ($FORMATTER as $key => $value) :
-?>
-							<option value="<?php echo $key; ?>"<?php
-        if ($key === $FORMATTERDEFAULT) :
-?> selected="selected"<?php
-        endif;
-?>><?php echo $value; ?></option>
-<?php
-    endforeach;
-?>
-						</select>
-						<a id="formatter" href="#" class="hidden dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><?php echo I18n::_('Format'); ?>: <span id="pasteFormatterDisplay"><?php echo $FORMATTER[$FORMATTERDEFAULT]; ?></span> <span class="caret"></span></a>
-						<ul class="dropdown-menu">
-<?php
-    foreach ($FORMATTER as $key => $value) :
-?>
-							<li>
-								<a href="#" data-format="<?php echo $key; ?>">
-									<?php echo $value, PHP_EOL; ?>
-								</a>
-							</li>
-<?php
-    endforeach;
-?>
-						</ul>
-					</li>
-<?php
-endif;
-?>
-				</ul>
-				<ul class="nav navbar-nav pull-right">
-<?php
-if (!empty($LANGUAGESELECTION)) :
-?>
-					<li id="language" class="dropdown">
-						<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><span class="glyphicon glyphicon-flag" aria-hidden="true"></span> <?php echo $LANGUAGES[$LANGUAGESELECTION][0]; ?> <span class="caret"></span></a>
-						<ul class="dropdown-menu dropdown-menu-right">
-<?php
-    foreach ($LANGUAGES as $key => $value) :
-?>
-							<li>
-								<a href="#" data-lang="<?php echo $key; ?>">
-									<?php echo $value[0]; ?> (<?php echo $value[1]; ?>)
-								</a>
-							</li>
-<?php
-    endforeach;
-?>
-						</ul>
-					</li>
-<?php
-endif;
-?>
-<?php
-if (!empty($TEMPLATESELECTION)) :
-?>
-					<li id="template" class="dropdown">
-						<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false"><?php echo I18n::_('Theme'); ?>: <?php echo $TEMPLATESELECTION; ?> <span class="caret"></span></a>
-						<ul class="dropdown-menu dropdown-menu-right">
-<?php
-    foreach ($TEMPLATES as $value) :
-?>
-							<li>
-								<a href="#" data-template="<?php echo $value; ?>">
-									<?php echo $value; ?>
-								</a>
-							</li>
-<?php
-    endforeach;
-?>
-						</ul>
-					</li>
-<?php
-endif;
-?>
-				</ul>
-			</div>
-		<?php
-if ($isCpct) :
-?></div><?php
-endif;
-?></nav>
-		<main>
-			<section class="container">
-<?php
-if (!empty($NOTICE)) :
-?>
-				<div role="alert" class="alert alert-info">
-					<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
-					<?php echo I18n::encode($NOTICE), PHP_EOL; ?>
-				</div>
-<?php
-endif;
-?>
-				<div id="remainingtime" role="alert" class="hidden alert alert-info">
-					<span class="glyphicon glyphicon-fire" aria-hidden="true"></span>
-				</div>
-<?php
-if ($FILEUPLOAD) :
-?>
-				<div id="attachment" class="hidden"></div>
-<?php
-endif;
-?>
-				<div id="status" role="alert" class="clearfix alert alert-<?php echo $ISDELETED ? 'success' : 'info'; echo empty($STATUS) ? ' hidden' : '' ?>">
-					<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
-					<?php echo I18n::encode($STATUS), PHP_EOL; ?>
-<?php
-if ($ISDELETED) :
-?>
-					<button type="button" class="btn btn-default pull-right" id="new-from-alert">
-						<span class="glyphicon glyphicon-repeat"></span>
-						<?php echo I18n::_('Start over'), PHP_EOL; ?>
-					</button>
-<?php
-endif;
-?>
-				</div>
-				<div id="errormessage" role="alert" class="<?php echo empty($ERROR) ? 'hidden' : '' ?> alert alert-danger">
-					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
-					<?php echo I18n::encode($ERROR), PHP_EOL; ?>
-				</div>
-				<noscript>
-					<div id="noscript" role="alert" class="alert alert-<?php echo $isDark ? 'error' : 'warning'; ?>">
-						<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-						<?php echo I18n::_('JavaScript is required for %s to work. Sorry for the inconvenience.', I18n::_($NAME)), PHP_EOL; ?>
-					</div>
-				</noscript>
-				<div id="oldnotice" role="alert" class="hidden alert alert-danger">
-					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
-					<?php echo I18n::_('%s requires a modern browser to work.', I18n::_($NAME)), PHP_EOL; ?>
-					<a href="https://www.mozilla.org/firefox/">Firefox</a>,
-					<a href="https://www.opera.com/">Opera</a>,
-					<a href="https://www.google.com/chrome">Chrome</a>…<br />
-					<span class="small"><?php echo I18n::_('For more information <a href="%s">see this FAQ entry</a>.', 'https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-it-show-me-the-error-privatebin-requires-a-modern-browser-to-work'); ?></span>
-				</div>
-<?php
-if ($HTTPWARNING) :
-?>
-				<div id="httpnotice" role="alert" class="hidden alert alert-danger">
-					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
-					<?php echo I18n::_('This website is using an insecure connection! Please only use it for testing.'); ?><br />
-					<span class="small"><?php echo I18n::_('For more information <a href="%s">see this FAQ entry</a>.', 'https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-it-show-me-an-error-about-an-insecure-connection'); ?></span>
-				</div>
-				<div id="insecurecontextnotice" role="alert" class="hidden alert alert-danger">
-					<span class="glyphicon glyphicon-alert" aria-hidden="true"></span>
-					<?php echo I18n::_('Your browser may require an HTTPS connection to support the WebCrypto API. Try <a href="%s">switching to HTTPS</a>.', $HTTPSLINK), PHP_EOL; ?>
-				</div>
-<?php
-endif;
-?>
-				<div id="pastesuccess" class="hidden">
-					<div class="nav nav-justified">
-						<button id="copyLink" type="button" class="btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-duplicate" aria-hidden="true"></span> <?php echo I18n::_('Copy link') ?>
-						</button>
-						<a href="#" id="deletelink" class="btn btn-<?php echo $isDark ? 'warning' : 'default'; ?> navbar-btn">
-							<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
-							<span></span>
-						</a>
-					</div>
-					<div role="alert" class="alert alert-success">
-						<span class="glyphicon glyphicon-ok" aria-hidden="true"></span>
-						<div id="pastelink"></div>
-					</div>
-<?php
-if (!empty($URLSHORTENER)) :
-?>
-					<p>
-						<button id="shortenbutton" data-shortener="<?php echo I18n::encode($URLSHORTENER); ?>"
-								<?php if ($SHORTENBYDEFAULT) : ?>
-								data-autoshorten="true"
-								<?php endif; ?>
-								type="button" class="btn btn-<?php echo $isDark ? 'warning' : 'primary'; ?> btn-block"
-						>
-							<span class="glyphicon glyphicon-send" aria-hidden="true"></span> <?php echo I18n::_('Shorten URL'), PHP_EOL; ?>
-						</button>
-					</p>
-					<div role="alert" class="alert alert-danger">
-						<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-						<?php if ($SHORTENBYDEFAULT) : ?>
-							<?php echo I18n::_('URL shortener is enabled by default.'), PHP_EOL; ?>
-						<?php endif; ?>
-						<?php echo I18n::_('URL shortener may expose your decrypt key in URL.'), PHP_EOL; ?>
-					</div>
-<?php
-endif;
-?>
-				</div>
-				<ul id="editorTabs" class="nav nav-tabs hidden">
-					<li role="presentation" class="active"><a role="tab" aria-selected="true" aria-controls="editorTabs" id="messageedit" href="#"><?php echo I18n::_('Editor'); ?></a></li>
-					<li role="presentation"><a role="tab" aria-selected="false" aria-controls="editorTabs" id="messagepreview" href="#"><?php echo I18n::_('Preview'); ?></a></li>
-					<li role="presentation" class="pull-right">
-<?php
-if ($isPage) :
-?>
-						<button id="newbutton" type="button" class="reloadlink hidden btn btn-<?php echo $isDark ? 'warning' : 'default'; ?>">
-							<span class="glyphicon glyphicon-file" aria-hidden="true"></span> <?php echo I18n::_('New'), PHP_EOL;
-else :
-?>
-						<button id="sendbutton" type="button" tabindex="2" class="hidden btn btn-<?php echo $isDark ? 'warning' : 'primary'; ?>">
-							<span class="glyphicon glyphicon-upload" aria-hidden="true"></span> <?php echo I18n::_('Create'), PHP_EOL;
-endif;
-?>
-						</button>
-					</li>
-				</ul>
-			</section>
-			<section class="container">
-				<article class="row">
-					<div id="placeholder" class="col-md-12 hidden"><?php echo I18n::_('+++ no document text +++'); ?></div>
-					<div id="attachmentPreview" class="col-md-12 text-center hidden"></div>
-					<h5 id="copyShortcutHint" class="media col-md-12 hidden" style="margin-top: 0;">
-						<div class="media-body media-middle">
-							<small id="copyShortcutHintText" class="hidden-xs">
-								<?php
-									echo I18n::_("To copy document press on the copy button or use the clipboard shortcut <kbd>%s</kbd>+<kbd>c</kbd>", I18n::getCopyHotkey())
-								?>
-							</small>
-						</div>
-						<div class="media-right media-middle">
-							<button type="button" id="copyShortcutHintBtn" class="btn btn-default"><?php echo I18n::_('Copy'); ?></button>
-						</div>
-					</h5>
-					<div id="prettymessage" class="col-md-12 hidden">
-						<pre id="prettyprint" class="col-md-12 prettyprint linenums:1"></pre>
-					</div>
-					<div id="plaintext" class="col-md-12 hidden"></div>
-					<p class="col-md-12"><textarea id="message" name="message" cols="80" rows="25" tabindex="1" class="form-control hidden"></textarea></p>
-					<p class="col-md-12 checkbox">
-						<label>
-							<input id="messagetab" type="checkbox" tabindex="3" checked="checked" />
-							<?php echo I18n::_('Tabulator key serves as character (Hit <kbd>Ctrl</kbd>+<kbd>m</kbd> or <kbd>Esc</kbd> to toggle)'), PHP_EOL; ?>
-						</label>
-					</p>
-				</article>
-			</section>
-			<section class="container">
-				<div id="discussion" class="hidden">
-					<h4><?php echo I18n::_('Discussion'); ?></h4>
-					<div id="commentcontainer"></div>
-				</div>
-			</section>
-			<section class="container">
-				<div id="noscript" role="alert" class="alert alert-info noscript-hide">
-					<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
-					<?php echo I18n::_('Loading…'); ?><br />
-					<span class="small"><?php echo I18n::_('In case this message never disappears please have a look at <a href="%s">this FAQ for information to troubleshoot</a>.', 'https://github.com/PrivateBin/PrivateBin/wiki/FAQ#why-does-the-loading-message-not-go-away'); ?></span>
-				</div>
-			</section>
-			<footer class="container">
-				<div class="row">
-					<h4 class="col-md-5 col-xs-8"><?php echo I18n::_($NAME); ?> <small>- <?php echo I18n::_('Because ignorance is bliss'); ?></small></h4>
-					<p class="col-md-1 col-xs-4 text-center"><?php echo $VERSION; ?></p>
-					<p id="aboutbox" class="col-md-6 col-xs-12">
-						<?php echo sprintf(
-                            I18n::_('%s is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted %sin the browser%s using 256 bits AES.',
-                                I18n::_($NAME),
-                                '%s', '%s'
-                            ),
-                            '<i>', '</i>'), ' ', $INFO, PHP_EOL;
-                        ?>
-					</p>
-				</div>
-			</footer>
-		</main>
-		<div id="serverdata" class="hidden" aria-hidden="true">
-			<div id="templates">
-				<article id="commenttemplate" class="comment">
-					<div class="commentmeta">
-						<span class="nickname">name</span>
-						<span class="commentdate">0000-00-00</span>
-					</div>
-					<div class="commentdata">c</div>
-					<button class="btn btn-default btn-sm"><?php echo I18n::_('Reply'); ?></button>
-				</article>
-				<p id="commenttailtemplate" class="comment">
-					<button class="btn btn-default btn-sm"><?php echo I18n::_('Add comment'); ?></button>
-				</p>
-				<div id="replytemplate" class="reply hidden">
-					<input type="text" id="nickname" class="form-control" title="<?php echo I18n::_('Optional nickname…'); ?>" placeholder="<?php echo I18n::_('Optional nickname…'); ?>" />
-					<textarea id="replymessage" class="replymessage form-control" cols="80" rows="7"></textarea><br />
-					<div id="replystatus" role="alert" class="statusmessage hidden alert">
-						<span class="glyphicon" aria-hidden="true"></span>
-					</div>
-					<button id="replybutton" class="btn btn-default btn-sm"><?php echo I18n::_('Post comment'); ?></button>
-				</div>
-				<div id="attachmenttemplate" role="alert" class="attachment hidden alert alert-info">
-					<span class="glyphicon glyphicon-download-alt" aria-hidden="true"></span>
-					<a class="alert-link"><?php echo I18n::_('Download attachment'); ?><span></span></a>
-				</div>
-			</div>
-		</div>
-<?php
-if ($FILEUPLOAD) :
-?>
-		<div id="dropzone" class="hidden" tabindex="-1" aria-hidden="true"></div>
-<?php
-endif;
-?>
-	</body>
-</html>

+ 0 - 1
tpl/bootstrap5.php

@@ -26,7 +26,6 @@ endif;
 ?>
 		<noscript><link type="text/css" rel="stylesheet" href="css/noscript.css" /></noscript>
 		<?php $this->_linkTag('js/zlib-1.3.2.js'); ?>
-		<?php $this->_scriptTag('js/jquery-3.7.1.js', 'defer'); ?>
 <?php
 if ($QRCODE) :
 ?>

+ 0 - 19
tst/ViewTest.php

@@ -76,25 +76,6 @@ class ViewTest extends TestCase
                 ob_end_clean();
             }
         }
-        // check bootstrap variants
-        $template = 'bootstrap-page';
-        ob_start();
-        $page->draw($template);
-        $this->_content[$template] = ob_get_contents();
-        ob_end_clean();
-        foreach (['-dark', '-compact'] as $suffix) {
-            $template = 'bootstrap' . $suffix;
-            ob_start();
-            $page->draw($template);
-            $this->_content[$template] = ob_get_contents();
-            ob_end_clean();
-
-            $template .= '-page';
-            ob_start();
-            $page->draw($template);
-            $this->_content[$template] = ob_get_contents();
-            ob_end_clean();
-        }
     }
 
     public function testTemplateRendersCorrectly()

Някои файлове не бяха показани, защото твърде много файлове са промени