From 4d19d033fba6e7c06815e4608c516ffeffc05753 Mon Sep 17 00:00:00 2001 From: Hitesh Dugar Date: Tue, 16 Jun 2026 00:48:54 +0530 Subject: [PATCH 1/2] a11y: make ins/del diff markup accessible to screen readers --- cp/appian-component-plugin.xml | 2 +- cp/richTextFieldWithTables/v1/custom.css | 7 ++++ cp/richTextFieldWithTables/v1/index.js | 48 ++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cp/appian-component-plugin.xml b/cp/appian-component-plugin.xml index 4413ab5..5755023 100644 --- a/cp/appian-component-plugin.xml +++ b/cp/appian-component-plugin.xml @@ -4,7 +4,7 @@ A simple rich text editor - 1.19.0 + 1.19.1 2.0.0 diff --git a/cp/richTextFieldWithTables/v1/custom.css b/cp/richTextFieldWithTables/v1/custom.css index 2f7bf8d..7aad6dc 100644 --- a/cp/richTextFieldWithTables/v1/custom.css +++ b/cp/richTextFieldWithTables/v1/custom.css @@ -187,3 +187,10 @@ button:focus { outline-offset: -2px !important; outline-width: 0.5px !important; } +/* Override browser defaults for ins/del to let font color/strike handle the visual styling */ +ins { + text-decoration: none; +} +del { + text-decoration: none; +} diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index f9681b1..b740b61 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -115,6 +115,8 @@ const ALLOWED_TAGS = [ "em", "u", "strike", + "ins", + "del", "sup", "sub", "font", @@ -523,6 +525,49 @@ function isTextPresent(text) { return html.includes(text); } +/** + * Post-processes the readOnly DOM to replace and elements with + * aria-labeled elements. This prevents VoiceOver from double-reading + * the content while still announcing "added:" or "removed:" for screen readers. + * Only affects the rendered DOM — the stored richText value is never modified. + */ +function makeInsDelAccessible() { + var container = document.getElementById("summernote"); + if (!container) return; + + container.querySelectorAll("ins").forEach(function (el) { + var span = document.createElement("span"); + span.setAttribute("role", "img"); + span.setAttribute("aria-label", "added: " + escapeAttr(el.textContent)); + if (el.getAttribute("style")) { + span.setAttribute("style", el.getAttribute("style")); + } + span.innerHTML = el.innerHTML; + el.replaceWith(span); + }); + + container.querySelectorAll("del").forEach(function (el) { + var span = document.createElement("span"); + span.setAttribute("role", "img"); + span.setAttribute("aria-label", "removed: " + escapeAttr(el.textContent)); + if (el.getAttribute("style")) { + span.setAttribute("style", el.getAttribute("style")); + } + span.innerHTML = el.innerHTML; + el.replaceWith(span); + }); +} + +/** + * Escapes double quotes in a string for safe use in HTML attribute values. + * @param {string} str - The string to escape + * @return {string} The escaped string + */ +function escapeAttr(str) { + if (!str) return ""; + return str.replace(/"/g, """); +} + /** * Updates the editor content HTML value from the Appian SAIL parameter, only updating if there is a change */ @@ -532,6 +577,9 @@ function setEditorContents() { // Then immediately destroy since setting the contents creates it summernote.summernote("code", cleanHtml(window.allParameters.richText)); summernote.summernote("destroy"); + // Post-process for accessibility: replace / with aria-labeled spans + // to prevent VoiceOver double-reading while maintaining screen reader announcements + makeInsDelAccessible(); } else { // Otherwise, only update the contents if they've actually changed to avoid triggering the onChange event if ( From 9447e89256e52403ac00561cd3e52deb6fa5cb59 Mon Sep 17 00:00:00 2001 From: Dan Tobias Date: Tue, 16 Jun 2026 12:58:54 -0400 Subject: [PATCH 2/2] Add test, add i18n, version bump --- .github/workflows/ci.yml | 6 +- cp/.eslintrc.json | 9 + cp/.prettierignore | 5 + cp/appian-component-plugin.xml | 2 +- cp/jest.config.js | 8 +- cp/package.json | 12 +- cp/richTextField/v1/i18n.js | 6 +- cp/richTextField/v1/index.js | 23 ++- cp/richTextFieldWithTables/v1/i18n.js | 14 +- cp/richTextFieldWithTables/v1/index.js | 23 +-- cp/tests/helpers/browserScriptTransform.js | 12 +- cp/tests/helpers/loadRichTextField.js | 4 +- .../helpers/loadRichTextFieldWithTables.js | 10 +- cp/tests/helpers/setupGlobals.js | 10 +- cp/tests/richTextField/utilities.test.js | 30 ++-- .../cleanHtml.pasteEnhancements.test.js | 6 +- .../cleanHtml.security.test.js | 31 +--- .../richTextFieldWithTables/cleanHtml.test.js | 131 ++++++-------- .../makeInsDelAccessible.test.js | 166 ++++++++++++++++++ .../pasteHandling.test.js | 6 +- .../stripSummernoteDefaults.test.js | 7 +- test.sh | 46 +++++ 22 files changed, 371 insertions(+), 196 deletions(-) create mode 100644 cp/.prettierignore create mode 100644 cp/tests/richTextFieldWithTables/makeInsDelAccessible.test.js create mode 100755 test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3e6a19..53fb16b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,13 +106,13 @@ jobs: run: npm install - name: ESLint - run: npx eslint 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js' + run: npm run lint - name: Prettier check - run: npx prettier --config prettierrc.json --check 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js' + run: npm run format:check - name: Run tests - run: npx jest --coverage --ci + run: npm test -- --watchAll=false --ci - name: npm audit run: npm audit --audit-level=high diff --git a/cp/.eslintrc.json b/cp/.eslintrc.json index e224971..3abfdbe 100644 --- a/cp/.eslintrc.json +++ b/cp/.eslintrc.json @@ -11,6 +11,15 @@ "english_translations": "readonly", "french_translations": "readonly" }, + "ignorePatterns": [ + "*.min.js", + "*.config.js", + "**/i18n.js", + "summernote*.js", + "jquery*.js", + "tests/", + "node_modules/" + ], "parserOptions": { "ecmaVersion": 2020, "sourceType": "script" diff --git a/cp/.prettierignore b/cp/.prettierignore new file mode 100644 index 0000000..ca9fa24 --- /dev/null +++ b/cp/.prettierignore @@ -0,0 +1,5 @@ +*.min.js +summernote*.js +jquery*.js +node_modules/ +coverage/ diff --git a/cp/appian-component-plugin.xml b/cp/appian-component-plugin.xml index 5755023..f33d948 100644 --- a/cp/appian-component-plugin.xml +++ b/cp/appian-component-plugin.xml @@ -4,7 +4,7 @@ A simple rich text editor - 1.19.1 + 1.20.0 2.0.0 diff --git a/cp/jest.config.js b/cp/jest.config.js index d205d95..2d03b34 100644 --- a/cp/jest.config.js +++ b/cp/jest.config.js @@ -5,15 +5,11 @@ module.exports = { transform: { // Use our custom transform for the source index.js files "richTextField/v1/index\\.js$": "./tests/helpers/browserScriptTransform.js", - "richTextFieldWithTables/v1/index\\.js$": - "./tests/helpers/browserScriptTransform.js", + "richTextFieldWithTables/v1/index\\.js$": "./tests/helpers/browserScriptTransform.js", }, // Don't transform node_modules, but DO transform our source files transformIgnorePatterns: ["/node_modules/"], - collectCoverageFrom: [ - "richTextField/v1/index.js", - "richTextFieldWithTables/v1/index.js", - ], + collectCoverageFrom: ["richTextField/v1/index.js", "richTextFieldWithTables/v1/index.js"], coverageDirectory: "coverage", coverageReporters: ["text", "lcov", "clover"], }; diff --git a/cp/package.json b/cp/package.json index 6f23d84..1fe3c30 100644 --- a/cp/package.json +++ b/cp/package.json @@ -6,15 +6,15 @@ "scripts": { "test": "jest --coverage", "test:watch": "jest --watch", - "lint": "eslint 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js'", - "format": "prettier --config prettierrc.json --write 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js'", - "format:check": "prettier --config prettierrc.json --check 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js'" + "lint": "eslint '**/*.js' --ignore-pattern '*.min.js'", + "format": "prettier --config prettierrc.json --write '**/*.js'", + "format:check": "prettier --config prettierrc.json --check '**/*.js'" }, "devDependencies": { - "eslint": "8.57.1", "@eslint/js": "8.57.1", - "prettier": "3.3.3", + "eslint": "8.57.1", "jest": "29.7.0", - "jest-environment-jsdom": "29.7.0" + "jest-environment-jsdom": "29.7.0", + "prettier": "3.3.3" } } diff --git a/cp/richTextField/v1/i18n.js b/cp/richTextField/v1/i18n.js index 0c64e8f..5634d31 100644 --- a/cp/richTextField/v1/i18n.js +++ b/cp/richTextField/v1/i18n.js @@ -31,8 +31,7 @@ const english_translations = { "The image storage connected system parameter is empty. Please update the parameter 'imageStorageConnectedSystem' with a valid connected system or set 'allowImages' to false.", validationContentTooBig: "Content exceeds maximum allowed size", validationConnectedSystemResponse: "Response from connected system:", - validationDocURLFailure: - "Unable to obtain the doc URL from the connected system", + validationDocURLFailure: "Unable to obtain the doc URL from the connected system", default: "Default", }; const french_translations = { @@ -67,7 +66,6 @@ const french_translations = { "Le paramètre du système connecté pour le stockage des images n'est pas renseigné. Veuillez mettre à jour le paramètre 'imageStorageConnectedSystem' en sélectionnant un système connecté valide ou mettre le paramètre 'allowImages' à faux.", validationContentTooBig: "Ce contenu dépasse la taille maximum autorisée", validationConnectedSystemResponse: "Réponse du système connecté :", - validationDocURLFailure: - "Impossible d'obtenir l'URL du document à partir du système connecté", + validationDocURLFailure: "Impossible d'obtenir l'URL du document à partir du système connecté", default: "Réglage par défaut", }; diff --git a/cp/richTextField/v1/index.js b/cp/richTextField/v1/index.js index 4034027..985f6ce 100644 --- a/cp/richTextField/v1/index.js +++ b/cp/richTextField/v1/index.js @@ -239,7 +239,7 @@ Appian.Component.onNewValue(function (allParameters) { /* Skip if recently blurred */ if (!window.isQuillBlurred) { /* Skip if an image is present that has not been converted to a file yet */ - if (source == "user" && !doesBase64ImageExist(quill.getContents())) { + if (source === "user" && !doesBase64ImageExist(quill.getContents())) { window.isQuillActive = true; updateValue(); } @@ -330,7 +330,7 @@ function updateValue() { const contents = quill.getContents(); /* Save value (Quill always adds single newline at end, so treat that as null) */ /* Check getLength() in case an image is added without any text */ - if (quill.getText() === "\n" && quill.getLength() == 1) { + if (quill.getText() === "\n" && quill.getLength() === 1) { Appian.Component.saveValue("richText", null); } else { // Due to race conditions, we were saving out base64 images in some cases @@ -380,7 +380,7 @@ function handleDisplay(enableProgressBar, height, placeholder) { quillContainer.style.minHeight = ""; parentContainer.style.minHeight = ""; } else { - if (height == "auto") { + if (height === "auto") { /* For "auto" height, start with a min height but allow to grow taller as content increases */ quillContainer.style.height = "auto"; parentContainer.style.height = "auto"; @@ -548,6 +548,7 @@ function uploadBase64Img(imageSelector) { docID = response.payload.docID; if (docURL == null) { + // eslint-disable-line eqeqeq message = getTranslation("validationDocURLFailure"); console.error(message); Appian.Component.setValidations(message); @@ -571,7 +572,7 @@ function uploadBase64Img(imageSelector) { } } - base64Str = imageSelector.getAttribute("src"); + var base64Str = imageSelector.getAttribute("src"); if (typeof base64Str !== "string" || base64Str.length < 100) { return base64Str; } @@ -618,10 +619,10 @@ function getBrowserAndVersion() { } if (M[1] === "Chrome") { tem = ua.match(/\b(OPR|Edge)\/(\d+)/); - if (tem != null) return tem.slice(1).join(" ").replace("OPR", "Opera"); + if (tem !== null) return tem.slice(1).join(" ").replace("OPR", "Opera"); } M = M[2] ? [M[1], M[2]] : [navigator.appName, navigator.appVersion, "-?"]; - if ((tem = ua.match(/version\/(\d+)/i)) != null) M.splice(1, 1, tem[1]); + if ((tem = ua.match(/version\/(\d+)/i)) !== null) M.splice(1, 1, tem[1]); return M.join(" "); } @@ -632,8 +633,9 @@ function getBrowserAndVersion() { function initializeCopyPaste() { var browserArray = getBrowserAndVersion().split(" "); var browser = browserArray[0]; + // eslint-disable-next-line no-unused-vars var browserVersion = browserArray[1]; - if (browser != "Firefox" && browser != "Chrome") { + if (browser !== "Firefox" && browser !== "Chrome") { var IMAGE_MIME_REGEX = /^image\/(p?jpeg|gif|png)$/i; var loadImage = function (file) { var reader = new FileReader(); @@ -665,11 +667,13 @@ function initializeCopyPaste() { // - https://site-appiancloud.com/suite/sites/.... (IE) // - https://site-appiancloud.com/ (Chrome, Firefox) // - https://site-appiancloud.com (Safari) +// eslint-disable-next-line no-unused-vars function returnParentWindowUrl() { return document.referrer.match(/^.*(?=\/suite\/.*)|^.*(?=\/$)|^.*$/g)[0]; } function translateToolbar() { + // eslint-disable-next-line no-unused-vars var toolbar = document.getElementById("quill-toolbar"); var nodesToTranslate = document.querySelectorAll("[data-i18n]"); @@ -678,13 +682,14 @@ function translateToolbar() { var node = nodeArray[i]; var i18nAttr = node.getAttribute("data-i18n"); var translatedValue; + var key; if (i18nAttr === "innerText") { - var key = node.innerText; + key = node.innerText; translatedValue = getTranslation(key); if (!translatedValue) continue; node.innerText = translatedValue; } else { - var key = node.getAttribute(i18nAttr); + key = node.getAttribute(i18nAttr); translatedValue = getTranslation(key); if (!translatedValue) continue; node.setAttribute(i18nAttr, translatedValue); diff --git a/cp/richTextFieldWithTables/v1/i18n.js b/cp/richTextFieldWithTables/v1/i18n.js index 3563315..a54943c 100644 --- a/cp/richTextFieldWithTables/v1/i18n.js +++ b/cp/richTextFieldWithTables/v1/i18n.js @@ -7,20 +7,26 @@ const english_translations = { textHeaderMedium: "Medium Header", textHeaderSmall: "Small Header", textNormal: "Normal Text", - validationImageStorageConnectedSystemEmpty: "The image storage connected system parameter is empty. Please update the parameter 'imageStorageConnectedSystem' with a valid connected system or set 'allowImages' to false.", + validationImageStorageConnectedSystemEmpty: + "The image storage connected system parameter is empty. Please update the parameter 'imageStorageConnectedSystem' with a valid connected system or set 'allowImages' to false.", validationContentTooBig: "Content exceeds maximum allowed size", validationConnectedSystemResponse: "Response from connected system:", validationDocURLFailure: "Unable to obtain the doc URL from the connected system", - default: "Default" + default: "Default", + added: "Added: ", + removed: "Removed: ", }; const french_translations = { textHeaderLarge: "Grand en-tête", textHeaderMedium: "Moyen en-tête", textHeaderSmall: "Petit en-tête", textNormal: "Texte normal", - validationImageStorageConnectedSystemEmpty: "Le paramètre du système connecté pour le stockage des images n'est pas renseigné. Veuillez mettre à jour le paramètre 'imageStorageConnectedSystem' en sélectionnant un système connecté valide ou mettre le paramètre 'allowImages' à faux.", + validationImageStorageConnectedSystemEmpty: + "Le paramètre du système connecté pour le stockage des images n'est pas renseigné. Veuillez mettre à jour le paramètre 'imageStorageConnectedSystem' en sélectionnant un système connecté valide ou mettre le paramètre 'allowImages' à faux.", validationContentTooBig: "Ce contenu dépasse la taille maximum autorisée", validationConnectedSystemResponse: "Réponse du système connecté :", validationDocURLFailure: "Impossible d'obtenir l'URL du document à partir du système connecté", - default: "Réglage par défaut" + default: "Réglage par défaut", + added: "Ajouté : ", + removed: "Supprimé : ", }; diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index b740b61..aa1203e 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -254,7 +254,7 @@ function buildEditor() { .on("click", function (e) { var selectedItem = $(this).html(); insertableItemsFiltered.map(function (i) { - if (selectedItem == cleanHtml(i.label, true)) { + if (selectedItem === cleanHtml(i.label, true)) { context.invoke("editor.insertText", i.value); } }); @@ -439,7 +439,7 @@ function uploadBase64Img(imageSelector) { } } - base64Str = imageSelector.getAttribute("src"); + var base64Str = imageSelector.getAttribute("src"); if (typeof base64Str !== "string" || base64Str.length < 100) { return base64Str; } @@ -538,7 +538,7 @@ function makeInsDelAccessible() { container.querySelectorAll("ins").forEach(function (el) { var span = document.createElement("span"); span.setAttribute("role", "img"); - span.setAttribute("aria-label", "added: " + escapeAttr(el.textContent)); + span.setAttribute("aria-label", getTranslation("added") + escapeAttr(el.textContent)); if (el.getAttribute("style")) { span.setAttribute("style", el.getAttribute("style")); } @@ -549,7 +549,7 @@ function makeInsDelAccessible() { container.querySelectorAll("del").forEach(function (el) { var span = document.createElement("span"); span.setAttribute("role", "img"); - span.setAttribute("aria-label", "removed: " + escapeAttr(el.textContent)); + span.setAttribute("aria-label", getTranslation("removed") + escapeAttr(el.textContent)); if (el.getAttribute("style")) { span.setAttribute("style", el.getAttribute("style")); } @@ -650,18 +650,18 @@ function setDynamicCss() { function setA11yCss() { // set aria-hidden to false for the close buttons var close_buttons = document.getElementsByClassName("btn-close"); - for (var i = 0; i < close_buttons.length; i++) { - close_buttons[i].setAttribute("aria-hidden", "false"); + for (var j = 0; j < close_buttons.length; j++) { + close_buttons[j].setAttribute("aria-hidden", "false"); } // set aria-expanded to false for buttons that will expand var dropdowns = document.querySelectorAll('[data-bs-toggle="dropdown"]'); - for (var i = 0; i < dropdowns.length; i++) { - dropdowns[i].setAttribute("aria-expanded", "false"); + for (var k = 0; k < dropdowns.length; k++) { + dropdowns[k].setAttribute("aria-expanded", "false"); } // set aria-label to "formatting options" for toolbars var toolbars = document.querySelectorAll('[role="toolbar"]'); - for (var i = 0; i < toolbars.length; i++) { - toolbars[i].setAttribute("aria-label", "formatting options"); + for (var m = 0; m < toolbars.length; m++) { + toolbars[m].setAttribute("aria-label", "formatting options"); } } @@ -900,6 +900,7 @@ function readClipboard(e) { } } +// eslint-disable-next-line no-unused-vars function handleImagePasteFromFile(e) { var clipboardData = e.originalEvent.clipboardData; var items = clipboardData.items; @@ -945,7 +946,6 @@ function isInternetExplorer() { var ua = window.navigator.userAgent; var msie = ua.indexOf("MSIE "); msie = msie > 0 || !!navigator.userAgent.match(/Trident.*rv\:11\./); - var ffox = navigator.userAgent.toLowerCase().indexOf("firefox") > -1; return msie; } @@ -954,6 +954,7 @@ function isInternetExplorer() { * @param {function} func - Function to run on a delay * @param {integer} delay - MS to delay re-execution of the function */ +// eslint-disable-next-line no-unused-vars function debounce(func, delay) { var inDebounce; return function () { diff --git a/cp/tests/helpers/browserScriptTransform.js b/cp/tests/helpers/browserScriptTransform.js index 09fec55..1e5f3ff 100644 --- a/cp/tests/helpers/browserScriptTransform.js +++ b/cp/tests/helpers/browserScriptTransform.js @@ -45,6 +45,8 @@ if (typeof module !== 'undefined' && module.exports) { MAX_SIZE_DEFAULT: typeof MAX_SIZE_DEFAULT !== 'undefined' ? MAX_SIZE_DEFAULT : undefined, DISPLAY_PARAMS: typeof DISPLAY_PARAMS !== 'undefined' ? DISPLAY_PARAMS : undefined, stripSummernoteDefaults: typeof stripSummernoteDefaults !== 'undefined' ? stripSummernoteDefaults : undefined, + makeInsDelAccessible: typeof makeInsDelAccessible !== 'undefined' ? makeInsDelAccessible : undefined, + escapeAttr: typeof escapeAttr !== 'undefined' ? escapeAttr : undefined, }; } `; @@ -87,15 +89,9 @@ module.exports = { process(sourceText, sourcePath) { let code = sourceText; - if ( - sourcePath.includes("richTextFieldWithTables") && - sourcePath.endsWith("index.js") - ) { + if (sourcePath.includes("richTextFieldWithTables") && sourcePath.endsWith("index.js")) { code += RICH_TEXT_WITH_TABLES_EXPORTS; - } else if ( - sourcePath.includes("richTextField") && - sourcePath.endsWith("index.js") - ) { + } else if (sourcePath.includes("richTextField") && sourcePath.endsWith("index.js")) { code += RICH_TEXT_FIELD_EXPORTS; } diff --git a/cp/tests/helpers/loadRichTextField.js b/cp/tests/helpers/loadRichTextField.js index d5c0563..8704320 100644 --- a/cp/tests/helpers/loadRichTextField.js +++ b/cp/tests/helpers/loadRichTextField.js @@ -13,13 +13,13 @@ function loadModule() { // Load i18n first const i18nSource = fs.readFileSync( path.resolve(__dirname, "../../richTextField/v1/i18n.js"), - "utf8", + "utf8" ); // Load the main source const mainSource = fs.readFileSync( path.resolve(__dirname, "../../richTextField/v1/index.js"), - "utf8", + "utf8" ); // Mock Quill diff --git a/cp/tests/helpers/loadRichTextFieldWithTables.js b/cp/tests/helpers/loadRichTextFieldWithTables.js index de2b71c..983076f 100644 --- a/cp/tests/helpers/loadRichTextFieldWithTables.js +++ b/cp/tests/helpers/loadRichTextFieldWithTables.js @@ -76,9 +76,10 @@ function setupGlobals() { "The image storage connected system parameter is empty.", validationContentTooBig: "Content exceeds maximum allowed size", validationConnectedSystemResponse: "Response from connected system:", - validationDocURLFailure: - "Unable to obtain the doc URL from the connected system", + validationDocURLFailure: "Unable to obtain the doc URL from the connected system", default: "Default", + added: "Added: ", + removed: "Removed: ", }; global.french_translations = { textHeaderLarge: "Grand en-tête", @@ -89,9 +90,10 @@ function setupGlobals() { "Le paramètre du système connecté pour le stockage des images n'est pas renseigné.", validationContentTooBig: "Ce contenu dépasse la taille maximum autorisée", validationConnectedSystemResponse: "Réponse du système connecté :", - validationDocURLFailure: - "Impossible d'obtenir l'URL du document à partir du système connecté", + validationDocURLFailure: "Impossible d'obtenir l'URL du document à partir du système connecté", default: "Réglage par défaut", + added: "Ajouté : ", + removed: "Supprimé : ", }; global.Appian = { getLocale: jest.fn(() => "en-US"), diff --git a/cp/tests/helpers/setupGlobals.js b/cp/tests/helpers/setupGlobals.js index 124e3b8..1ba5252 100644 --- a/cp/tests/helpers/setupGlobals.js +++ b/cp/tests/helpers/setupGlobals.js @@ -119,9 +119,10 @@ global.english_translations = { "The image storage connected system parameter is empty.", validationContentTooBig: "Content exceeds maximum allowed size", validationConnectedSystemResponse: "Response from connected system:", - validationDocURLFailure: - "Unable to obtain the doc URL from the connected system", + validationDocURLFailure: "Unable to obtain the doc URL from the connected system", default: "Default", + added: "Added: ", + removed: "Removed: ", }; global.french_translations = { @@ -156,9 +157,10 @@ global.french_translations = { "Le paramètre du système connecté pour le stockage des images n'est pas renseigné.", validationContentTooBig: "Ce contenu dépasse la taille maximum autorisée", validationConnectedSystemResponse: "Réponse du système connecté :", - validationDocURLFailure: - "Impossible d'obtenir l'URL du document à partir du système connecté", + validationDocURLFailure: "Impossible d'obtenir l'URL du document à partir du système connecté", default: "Réglage par défaut", + added: "Ajouté : ", + removed: "Supprimé : ", }; // ── Appian SDK mock ────────────────────────────────────────────────── diff --git a/cp/tests/richTextField/utilities.test.js b/cp/tests/richTextField/utilities.test.js index e36abdb..f46e028 100644 --- a/cp/tests/richTextField/utilities.test.js +++ b/cp/tests/richTextField/utilities.test.js @@ -19,28 +19,26 @@ const { describe("revertIndentInlineToClass", () => { test("converts single indent to class", () => { - expect( - revertIndentInlineToClass('

text

'), - ).toBe('

text

'); + expect(revertIndentInlineToClass('

text

')).toBe( + '

text

' + ); }); test("converts double indent to class", () => { - expect( - revertIndentInlineToClass('

text

'), - ).toBe('

text

'); + expect(revertIndentInlineToClass('

text

')).toBe( + '

text

' + ); }); test("converts large indent values", () => { - expect( - revertIndentInlineToClass('

text

'), - ).toBe('

text

'); + expect(revertIndentInlineToClass('

text

')).toBe( + '

text

' + ); }); test("handles multiple indented paragraphs", () => { - const input = - '

one

three

'; - const expected = - '

one

three

'; + const input = '

one

three

'; + const expected = '

one

three

'; expect(revertIndentInlineToClass(input)).toBe(expected); }); @@ -121,8 +119,7 @@ describe("getBrowserAndVersion", () => { test("detects Firefox", () => { Object.defineProperty(navigator, "userAgent", { - value: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + value: "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", configurable: true, }); expect(getBrowserAndVersion()).toBe("Firefox 121"); @@ -130,8 +127,7 @@ describe("getBrowserAndVersion", () => { test("detects IE 11 via Trident", () => { Object.defineProperty(navigator, "userAgent", { - value: - "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", + value: "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", configurable: true, }); expect(getBrowserAndVersion()).toBe("IE 11"); diff --git a/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js b/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js index 7f4f681..569b831 100644 --- a/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js +++ b/cp/tests/richTextFieldWithTables/cleanHtml.pasteEnhancements.test.js @@ -81,8 +81,7 @@ describe("cleanHtml - newline inside tag attributes", () => { }); test("handles mixed: newlines in attributes and in text", () => { - const input = - '

before\nafter

'; + const input = '

before\nafter

'; const result = cleanHtml(input, true); // Newline in attribute should not produce
expect(result).not.toMatch(/style="[^"]*
[^"]*"/); @@ -116,8 +115,7 @@ describe("cleanHtml - updated link regex", () => { }); test("preserves links with custom protocol scheme", () => { - const input = - 'edge link'; + const input = 'edge link'; const result = cleanHtml(input); expect(result).toContain("href="); expect(result).toContain("edge link"); diff --git a/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js b/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js index f2e2023..6f107cc 100644 --- a/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js +++ b/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js @@ -12,9 +12,7 @@ describe("cleanHtml - XSS prevention", () => { }); test("strips ', - ); + const result = cleanHtml(''); expect(result).not.toContain(" { }); test("strips javascript: protocol in links", () => { - const result = cleanHtml( - 'click', - ); + const result = cleanHtml('click'); expect(result).not.toContain("javascript:"); expect(result).toContain("click"); }); test("strips data: protocol in links", () => { - const result = cleanHtml( - 'click', - ); + const result = cleanHtml('click'); expect(result).toContain("click"); }); test("strips ', - ); + const result = cleanHtml(''); expect(result).not.toContain(" tags", () => { const result = cleanHtml( - '', + '' ); expect(result).not.toContain(" { test("strips
tags", () => { const result = cleanHtml( - '
', + '
' ); expect(result).not.toContain(" tags", () => { - const result = cleanHtml( - '', - ); + const result = cleanHtml(''); expect(result).not.toContain(" { }); test("strips tags", () => { - const result = cleanHtml( - '', - ); + const result = cleanHtml(''); expect(result).not.toContain(" { describe("cleanHtml - edge cases", () => { test("handles deeply nested allowed tags", () => { - const input = - "

deep

"; + const input = "

deep

"; const result = cleanHtml(input); expect(result).toContain("deep"); expect(result).toContain(""); diff --git a/cp/tests/richTextFieldWithTables/cleanHtml.test.js b/cp/tests/richTextFieldWithTables/cleanHtml.test.js index cc604bd..6d889ef 100644 --- a/cp/tests/richTextFieldWithTables/cleanHtml.test.js +++ b/cp/tests/richTextFieldWithTables/cleanHtml.test.js @@ -48,39 +48,27 @@ describe("cleanHtml", () => { describe("partial HTML paste (isPartialHtml=true, starts with <)", () => { test("replaces \\r\\n with space (Word-style)", () => { - expect(cleanHtml("

hello\r\nworld

", true)).toBe( - "

hello world

", - ); + expect(cleanHtml("

hello\r\nworld

", true)).toBe("

hello world

"); }); test("replaces \\n with
", () => { - expect(cleanHtml("

hello\nworld

", true)).toBe( - "

hello
world

", - ); + expect(cleanHtml("

hello\nworld

", true)).toBe("

hello
world

"); }); test("removes whitespace between tags", () => { - expect(cleanHtml("

hello

world

", true)).toBe( - "

hello

world

", - ); + expect(cleanHtml("

hello

world

", true)).toBe("

hello

world

"); }); test("removes MsoNormal class (Word paste)", () => { - expect(cleanHtml('

hello

', true)).toBe( - "

hello

", - ); + expect(cleanHtml('

hello

', true)).toBe("

hello

"); }); test("removes MsoNormal with single quotes", () => { - expect(cleanHtml("

hello

", true)).toBe( - "

hello

", - ); + expect(cleanHtml("

hello

", true)).toBe("

hello

"); }); test("removes MsoNormal without quotes", () => { - expect(cleanHtml("

hello

", true)).toBe( - "

hello

", - ); + expect(cleanHtml("

hello

", true)).toBe("

hello

"); }); }); @@ -112,17 +100,12 @@ describe("cleanHtml", () => { }); test("preserves list tags", () => { - expect(cleanHtml("
  • item
")).toBe( - "
  • item
", - ); - expect(cleanHtml("
  1. item
")).toBe( - "
  1. item
", - ); + expect(cleanHtml("
  • item
")).toBe("
  • item
"); + expect(cleanHtml("
  1. item
")).toBe("
  1. item
"); }); test("preserves table tags", () => { - const input = - "
HD
"; + const input = "
HD
"; expect(cleanHtml(input)).toBe(input); }); @@ -143,13 +126,13 @@ describe("cleanHtml", () => { test("strips ")).toBe( - "

hello

alert('xss')", + "

hello

alert('xss')" ); }); test("strips

text

")).toBe( - ".red{color:red}

text

", + ".red{color:red}

text

" ); }); @@ -162,21 +145,17 @@ describe("cleanHtml", () => { }); test("strips

ok

')).toBe( - "

ok

", - ); + expect(cleanHtml('

ok

')).toBe("

ok

"); }); test("strips
and tags", () => { - expect( - cleanHtml('

ok

'), - ).toBe("

ok

"); + expect(cleanHtml('

ok

')).toBe( + "

ok

" + ); }); test("strips nested disallowed tags", () => { - expect(cleanHtml("
deep
")).toBe( - "deep", - ); + expect(cleanHtml("
deep
")).toBe("deep"); }); test("strips tags when not in ALLOWED_TAGS", () => { @@ -188,25 +167,25 @@ describe("cleanHtml", () => { describe("Step 3: Disallowed attribute removal", () => { test("preserves href attribute on ", () => { expect(cleanHtml('link')).toBe( - 'link', + 'link' ); }); test("preserves target attribute", () => { - expect( - cleanHtml('link'), - ).toBe('link'); + expect(cleanHtml('link')).toBe( + 'link' + ); }); test("preserves color attribute on font", () => { expect(cleanHtml('red')).toBe( - 'red', + 'red' ); }); test("preserves colspan and rowspan on td", () => { expect(cleanHtml('cell')).toBe( - 'cell', + 'cell' ); }); @@ -302,9 +281,7 @@ describe("cleanHtml", () => { }); test("strips javascript: links (keeps text)", () => { - expect(cleanHtml('click')).toBe( - "click", - ); + expect(cleanHtml('click')).toBe("click"); }); test("strips links with no protocol (keeps text)", () => { @@ -324,15 +301,11 @@ describe("cleanHtml", () => { }); test("removes multi-word comments", () => { - expect(cleanHtml("

text

")).toBe( - "

text

", - ); + expect(cleanHtml("

text

")).toBe("

text

"); }); test("removes comments between tags", () => { - expect(cleanHtml("

a

b

")).toBe( - "

a

b

", - ); + expect(cleanHtml("

a

b

")).toBe("

a

b

"); }); }); @@ -348,9 +321,7 @@ describe("cleanHtml", () => { }); test("collapses multiple spaces to single space", () => { - expect(cleanHtml("

too many spaces

")).toBe( - "

too many spaces

", - ); + expect(cleanHtml("

too many spaces

")).toBe("

too many spaces

"); }); }); @@ -422,77 +393,73 @@ describe("cleanHtml", () => { // ── Regression / acceptance tests ────────────────────────────────── describe("regression tests", () => { test("Remove Javascript", () => { - expect( - cleanHtml(""), - ).toBe("window.open('https://www.google.com');"); + expect(cleanHtml("")).toBe( + "window.open('https://www.google.com');" + ); }); test("Remove image", () => { expect( cleanHtml( - 'Site Icon', - ), + 'Site Icon' + ) ).toBe(""); }); test("Remove bad HTML tags, keep good ones", () => { expect( cleanHtml( - '

Hi there

', - ), - ).toBe( - '

Hi there

', - ); + '

Hi there

' + ) + ).toBe('

Hi there

'); }); test("Remove bad attributes, keep good ones, regardless of whether attributes have tag characters in them (<>)", () => { // NOTE: Attributes with > in their values break the tag-matching regex (known IE-compat limitation) expect( cleanHtml( - '

hi="you"Open tags

Close tags

', - ), + '

hi="you"Open tags

Close tags

' + ) ).toBe( - '

hi="you"Open tags

Close tags

', + '

hi="you"Open tags

Close tags

' ); }); test("Strip style attributes copied from an Appian web page (including " in an attribute)", () => { expect( cleanHtml( - 'interface style="inhtml: here; "', - ), + 'interface style="inhtml: here; "' + ) ).toBe( - 'interface style="inhtml: here; "', + 'interface style="inhtml: here; "' ); }); test("Strip style attributes that don't have spaces between them", () => { expect( cleanHtml( - 'interface style="inhtml:here;"', - ), + 'interface style="inhtml:here;"' + ) ).toBe( - 'interface style="inhtml:here;"', + 'interface style="inhtml:here;"' ); }); test("Strip trailing style attributes that don't end in semi-colon", () => { expect( cleanHtml( - '

Line 1

Line 2

', - ), - ).toBe( - '

Line 1

Line 2

', - ); + '

Line 1

Line 2

' + ) + ).toBe('

Line 1

Line 2

'); }); test("Strip non-external hyperlinks", () => { expect( cleanHtml( - 'Internal LinkGo to GoogleGo to Google httpDownload a fileLink with ProtocolEmail Dan!', - ), + 'Internal LinkGo to GoogleGo to Google httpDownload a fileLink with ProtocolEmail Dan!' + ) ).toBe( - 'Internal LinkGo to GoogleGo to Google httpDownload a fileLink with ProtocolEmail Dan!', + 'Internal LinkGo to GoogleGo to Google httpDownload a fileLink with ProtocolEmail Dan!' ); }); }); diff --git a/cp/tests/richTextFieldWithTables/makeInsDelAccessible.test.js b/cp/tests/richTextFieldWithTables/makeInsDelAccessible.test.js new file mode 100644 index 0000000..a0d9a4a --- /dev/null +++ b/cp/tests/richTextFieldWithTables/makeInsDelAccessible.test.js @@ -0,0 +1,166 @@ +/** + * Tests for makeInsDelAccessible() and escapeAttr() from richTextFieldWithTables/v1/index.js + * + * makeInsDelAccessible replaces and elements in the readOnly DOM + * with aria-labeled elements so screen readers announce "Added:" or + * "Removed:" without VoiceOver double-reading the content. + */ + +const { makeInsDelAccessible, escapeAttr } = require("../../richTextFieldWithTables/v1/index.js"); + +describe("escapeAttr", () => { + test("escapes double quotes", () => { + expect(escapeAttr('say "hello"')).toBe("say "hello""); + }); + + test("returns empty string for null/undefined", () => { + expect(escapeAttr(null)).toBe(""); + expect(escapeAttr(undefined)).toBe(""); + expect(escapeAttr("")).toBe(""); + }); + + test("leaves strings without quotes unchanged", () => { + expect(escapeAttr("plain text")).toBe("plain text"); + }); + + test("escapes multiple double quotes", () => { + expect(escapeAttr('"a" and "b"')).toBe(""a" and "b""); + }); +}); + +describe("makeInsDelAccessible", () => { + let container; + + beforeEach(() => { + container = document.getElementById("summernote"); + container.innerHTML = ""; + }); + + test("replaces with accessible span", () => { + container.innerHTML = "inserted text"; + makeInsDelAccessible(); + + const spans = container.querySelectorAll("span"); + expect(spans).toHaveLength(1); + expect(spans[0].getAttribute("role")).toBe("img"); + expect(spans[0].getAttribute("aria-label")).toBe("Added: inserted text"); + expect(spans[0].innerHTML).toBe("inserted text"); + }); + + test("replaces with accessible span", () => { + container.innerHTML = "deleted text"; + makeInsDelAccessible(); + + const spans = container.querySelectorAll("span"); + expect(spans).toHaveLength(1); + expect(spans[0].getAttribute("role")).toBe("img"); + expect(spans[0].getAttribute("aria-label")).toBe("Removed: deleted text"); + expect(spans[0].innerHTML).toBe("deleted text"); + }); + + test("preserves style attribute from ", () => { + container.innerHTML = 'styled insert'; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.getAttribute("style")).toBe("color: red;"); + }); + + test("preserves style attribute from ", () => { + container.innerHTML = 'styled delete'; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.getAttribute("style")).toBe("text-decoration: line-through;"); + }); + + test("does not add style attribute when element has none", () => { + container.innerHTML = "no style"; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.hasAttribute("style")).toBe(false); + }); + + test("handles multiple and elements", () => { + container.innerHTML = + "

added A normal removed B more added C

"; + makeInsDelAccessible(); + + const spans = container.querySelectorAll("span[role='img']"); + expect(spans).toHaveLength(3); + expect(spans[0].getAttribute("aria-label")).toBe("Added: added A"); + expect(spans[1].getAttribute("aria-label")).toBe("Removed: removed B"); + expect(spans[2].getAttribute("aria-label")).toBe("Added: added C"); + }); + + test("preserves inner HTML (nested elements) within ", () => { + container.innerHTML = "bold and italic"; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.innerHTML).toBe("bold and italic"); + // aria-label uses textContent, so it flattens the nested HTML + expect(span.getAttribute("aria-label")).toBe("Added: bold and italic"); + }); + + test("preserves inner HTML (nested elements) within ", () => { + container.innerHTML = "bold removed"; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.innerHTML).toBe("bold removed"); + expect(span.getAttribute("aria-label")).toBe("Removed: bold removed"); + }); + + test("escapes double quotes in aria-label", () => { + container.innerHTML = 'say "hello"'; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.getAttribute("aria-label")).toBe("Added: say "hello""); + }); + + test("no-ops when #summernote container does not exist", () => { + container.remove(); + expect(() => makeInsDelAccessible()).not.toThrow(); + + // Restore for other tests + const div = document.createElement("div"); + div.id = "summernote"; + document.body.appendChild(div); + }); + + test("no-ops when container has no or ", () => { + container.innerHTML = "

just a paragraph

"; + makeInsDelAccessible(); + + expect(container.innerHTML).toBe("

just a paragraph

"); + }); + + test("handles empty element", () => { + container.innerHTML = ""; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.getAttribute("aria-label")).toBe("Added: "); + expect(span.innerHTML).toBe(""); + }); + + test("handles empty element", () => { + container.innerHTML = ""; + makeInsDelAccessible(); + + const span = container.querySelector("span"); + expect(span.getAttribute("aria-label")).toBe("Removed: "); + expect(span.innerHTML).toBe(""); + }); + + test("removes all and elements from the DOM", () => { + container.innerHTML = "ab"; + makeInsDelAccessible(); + + expect(container.querySelectorAll("ins")).toHaveLength(0); + expect(container.querySelectorAll("del")).toHaveLength(0); + }); +}); diff --git a/cp/tests/richTextFieldWithTables/pasteHandling.test.js b/cp/tests/richTextFieldWithTables/pasteHandling.test.js index eafe7f1..c28ea7d 100644 --- a/cp/tests/richTextFieldWithTables/pasteHandling.test.js +++ b/cp/tests/richTextFieldWithTables/pasteHandling.test.js @@ -86,8 +86,7 @@ describe("isInternetExplorer", () => { test("returns true for IE 11 Trident user agent", () => { Object.defineProperty(navigator, "userAgent", { - value: - "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", + value: "Mozilla/5.0 (Windows NT 10.0; WOW64; Trident/7.0; rv:11.0) like Gecko", configurable: true, }); expect(isInternetExplorer()).toBe(true); @@ -132,8 +131,7 @@ describe("paste event flow", () => { }); test("paste from Excel with table structure is preserved", () => { - const excelPaste = - '
A1B1
'; + const excelPaste = '
A1B1
'; const cleaned = cleanHtml(excelPaste, true); expect(cleaned).toContain(""); expect(cleaned).toContain(" { test("returns empty string for empty input", () => { @@ -20,8 +18,7 @@ describe("stripSummernoteDefaults", () => { }); test("strips default background-color rgb(255,255,255)", () => { - const input = - 'text'; + const input = 'text'; const result = stripSummernoteDefaults(input); expect(result).not.toContain("background-color"); expect(result).toContain("text"); diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..f799bad --- /dev/null +++ b/test.sh @@ -0,0 +1,46 @@ +#!/bin/bash +set -e + +echo "========================================" +echo " Java Lint (Checkstyle, SpotBugs, PMD)" +echo "========================================" +cd csp +chmod +x gradlew +./gradlew checkstyleMain +./gradlew spotbugsMain +./gradlew pmdMain + +echo "" +echo "========================================" +echo " Java Tests" +echo "========================================" +./gradlew test + +cd .. + +echo "" +echo "========================================" +echo " JavaScript Lint & Format Check" +echo "========================================" +cd cp +npm install +npm run format +npm run lint +npm run format:check + +echo "" +echo "========================================" +echo " JavaScript Tests" +echo "========================================" +npm test + +echo "" +echo "========================================" +echo " npm audit" +echo "========================================" +npm audit --audit-level=high || true + +echo "" +echo "========================================" +echo " All checks passed!" +echo "========================================"