diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3e6a19 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + # ─── Full Build (Java CSP + Component Plugin zip) ─────────────────── + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('csp/**/*.gradle*', 'csp/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Run build.sh + run: | + chmod +x build.sh + ./build.sh + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: build/ + + # ─── Java Lint (Checkstyle + SpotBugs + PMD) ──────────────────────── + java-lint: + name: Java Lint + runs-on: ubuntu-latest + defaults: + run: + working-directory: csp + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('csp/**/*.gradle*', 'csp/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: ${{ runner.os }}-gradle- + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Checkstyle + run: ./gradlew checkstyleMain + + - name: SpotBugs + run: ./gradlew spotbugsMain + + - name: PMD + run: ./gradlew pmdMain + + - name: Upload lint reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: java-lint-reports + path: | + csp/build/reports/checkstyle/ + csp/build/reports/spotbugs/ + csp/build/reports/pmd/ + + # ─── JavaScript Lint + Test + CVE ──────────────────────────────────── + js: + name: JavaScript Lint & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: cp + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: ESLint + run: npx eslint 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js' + + - name: Prettier check + run: npx prettier --config prettierrc.json --check 'richTextField/v1/index.js' 'richTextFieldWithTables/v1/index.js' + + - name: Run tests + run: npx jest --coverage --ci + + - name: npm audit + run: npm audit --audit-level=high + continue-on-error: true + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: js-coverage + path: cp/coverage/ diff --git a/.gitignore b/.gitignore index 6a3c6ef..fd38653 100644 --- a/.gitignore +++ b/.gitignore @@ -13,12 +13,17 @@ /**/github-csp.properties # Inclusions -!/**/.idea/gradle.xml -!/**/.idea/misc.xml +!/**/gradle/ +!/**/gradle/wrapper/ +!/**/gradle/wrapper/gradle-wrapper.jar +!/**/gradle/wrapper/gradle-wrapper.properties +!/**/.settings/ !/**/.settings/greclipse.properties !/**/.settings/org.eclipse.jdt.core.prefs !/**/.settings/org.eclipse.jdt.ui.prefs !/**/.settings/org.eclipse.wst.xml.core.prefs +!/**/.idea/gradle.xml +!/**/.idea/misc.xml !/**/.idea/eclipseCodeFormatter.xml # Appian Plugins diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..fefe2b8 --- /dev/null +++ b/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" + +echo "=== Cleaning build directory ===" +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +# ─── Build Java CSP ────────────────────────────────────────────────── +# Uses java.toolchain in build.gradle to auto-provision JDK 17 for compilation +# while cross-compiling to Java 8 bytecode (options.release = 8) + +echo "" +echo "=== Building Java Connected System Plugin (csp) ===" +cd "$SCRIPT_DIR/csp" +chmod +x gradlew +./gradlew clean build -x spotbugsMain -x spotbugsTest + +# Copy the built jar to the build directory +CSP_JAR=$(find build/libs -name '*.jar' -not -name '*-sources.jar' | head -1) +if [ -z "$CSP_JAR" ]; then + echo "ERROR: No JAR found in csp/build/libs/" + exit 1 +fi +cp "$CSP_JAR" "$BUILD_DIR/" +echo " -> $(basename "$CSP_JAR") copied to build/" + +# ─── Build Component Plugin (cp) ───────────────────────────────────── + +echo "" +echo "=== Building Component Plugin (cp) ===" +cd "$SCRIPT_DIR/cp" + +# Read version from appian-component-plugin.xml +VERSION=$(grep '' appian-component-plugin.xml | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') +ZIP_NAME="ComponentPlugin_Rich_Text_v${VERSION}.zip" + +echo " Version: $VERSION" + +# Create a temp staging directory +STAGING_DIR=$(mktemp -d) +trap "rm -rf $STAGING_DIR" EXIT + +# Copy only the runtime files that belong in the plugin zip +cp appian-component-plugin.xml "$STAGING_DIR/" +rsync -a --exclude='.DS_Store' richTextField/ "$STAGING_DIR/richTextField/" +rsync -a --exclude='.DS_Store' richTextFieldWithTables/ "$STAGING_DIR/richTextFieldWithTables/" + +# Create the zip +cd "$STAGING_DIR" +zip -r "$BUILD_DIR/$ZIP_NAME" . -x '*.DS_Store' + +echo " -> $ZIP_NAME created in build/" + +# ─── Summary ───────────────────────────────────────────────────────── + +echo "" +echo "=== Build complete ===" +echo "Artifacts in $BUILD_DIR/:" +ls -lh "$BUILD_DIR/" diff --git a/cp/.eslintrc.json b/cp/.eslintrc.json new file mode 100644 index 0000000..e224971 --- /dev/null +++ b/cp/.eslintrc.json @@ -0,0 +1,27 @@ +{ + "env": { + "browser": true, + "es6": true, + "jquery": true + }, + "globals": { + "Appian": "readonly", + "Quill": "readonly", + "$": "readonly", + "english_translations": "readonly", + "french_translations": "readonly" + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "script" + }, + "rules": { + "no-undef": "warn", + "no-unused-vars": ["warn", { "args": "none" }], + "no-redeclare": "warn", + "eqeqeq": ["warn", "smart"], + "no-eval": "error", + "no-implied-eval": "error", + "no-new-func": "error" + } +} diff --git a/cp/.gitignore b/cp/.gitignore index dbdbb1c..0a432fc 100644 --- a/cp/.gitignore +++ b/cp/.gitignore @@ -1,4 +1,6 @@ *.zip **/.DS_Store .componentTester -**/node_modules \ No newline at end of file +**/node_modules +coverage +package-lock.json \ No newline at end of file diff --git a/cp/jest.config.js b/cp/jest.config.js new file mode 100644 index 0000000..d205d95 --- /dev/null +++ b/cp/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + testEnvironment: "jsdom", + testMatch: ["**/tests/**/*.test.js"], + setupFiles: ["./tests/helpers/setupGlobals.js"], + 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", + }, + // Don't transform node_modules, but DO transform our source files + transformIgnorePatterns: ["/node_modules/"], + 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 new file mode 100644 index 0000000..6f23d84 --- /dev/null +++ b/cp/package.json @@ -0,0 +1,20 @@ +{ + "name": "appian-rich-text-component-plugin", + "version": "1.17.2", + "private": true, + "description": "Appian Rich Text Editor Component Plugin", + "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'" + }, + "devDependencies": { + "eslint": "8.57.1", + "@eslint/js": "8.57.1", + "prettier": "3.3.3", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0" + } +} diff --git a/cp/prettierrc.json b/cp/prettierrc.json index b765584..fe54cb1 100644 --- a/cp/prettierrc.json +++ b/cp/prettierrc.json @@ -3,6 +3,5 @@ "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", - "useEditorConfig": false, "useTabs": false } diff --git a/cp/richTextField/v1/index.js b/cp/richTextField/v1/index.js index 585596d..a40b3c5 100644 --- a/cp/richTextField/v1/index.js +++ b/cp/richTextField/v1/index.js @@ -187,8 +187,7 @@ Appian.Component.onNewValue(function (allParameters) { document.querySelectorAll(buildCssSelector(format)) ); nodeArray.forEach(function (element) { - element.style.display = - allowedFormats.indexOf(format) >= 0 ? "block" : "none"; + element.style.display = allowedFormats.indexOf(format) >= 0 ? "block" : "none"; }); }); @@ -201,11 +200,8 @@ Appian.Component.onNewValue(function (allParameters) { } }); if (cssSelectors.length > 0) { - var elementsOfFormatList = document.querySelectorAll( - cssSelectors.join(",") - ); - var lastElementOfFormatList = - elementsOfFormatList[elementsOfFormatList.length - 1]; + var elementsOfFormatList = document.querySelectorAll(cssSelectors.join(",")); + var lastElementOfFormatList = elementsOfFormatList[elementsOfFormatList.length - 1]; lastElementOfFormatList.classList.add("ql-spacer"); } }); @@ -224,9 +220,7 @@ Appian.Component.onNewValue(function (allParameters) { }); /* Add aria-label for nested menu button elements */ - var pickerItemArray = Array.prototype.slice.call( - document.querySelectorAll(".ql-picker-item") - ); + var pickerItemArray = Array.prototype.slice.call(document.querySelectorAll(".ql-picker-item")); pickerItemArray.forEach(function (element) { var dataLabel = element.getAttribute("data-label"); var dataValue = element.getAttribute("data-value"); @@ -264,9 +258,7 @@ Appian.Component.onNewValue(function (allParameters) { */ if (window.allowImages) { quill.on("text-change", function (delta, oldDelta, source) { - const images = Array.prototype.slice.call( - quill.container.querySelectorAll("img") - ); + const images = Array.prototype.slice.call(quill.container.querySelectorAll("img")); images.forEach(function (image) { if (isImageNewBase64(image)) { image.classList.add("loading"); @@ -346,9 +338,7 @@ function updateColors() { // Transparency var backgroundColor = window.isReadOnly ? "transparent" : "#ffffff"; - cssStyles.push( - "#parent-container {background-color: " + backgroundColor + "}" - ); + cssStyles.push("#parent-container {background-color: " + backgroundColor + "}"); styleEl.innerHTML = cssStyles.join("\n"); } @@ -388,24 +378,20 @@ function handleDisplay(enableProgressBar, height, placeholder) { parentContainer.style.minHeight = ""; var heightInt = parseInt(height); /* Reserve ~60px for toolbar and progressBar. Reserve 45px for toolbar without progressBar */ - quillContainer.style.height = - heightInt - (showProgressBar ? 60 : 45) + "px"; + quillContainer.style.height = heightInt - (showProgressBar ? 60 : 45) + "px"; parentContainer.style.height = heightInt + "px"; } var quillEditor = document.getElementsByClassName("ql-editor")[0]; /* Subtract 2px to account for the 1px border (1px top + 1px bottom = 2px) on quill-container */ if (quillContainer.style.minHeight) { - quillEditor.style.minHeight = - parseInt(quillContainer.style.minHeight) - 2 + "px"; + quillEditor.style.minHeight = parseInt(quillContainer.style.minHeight) - 2 + "px"; } else { - quillEditor.style.height = - parseInt(quillContainer.style.height) - 2 + "px"; + quillEditor.style.height = parseInt(quillContainer.style.height) - 2 + "px"; } } /* Placeholder */ - quill.root.dataset.placeholder = - placeholder && !window.isReadOnly ? placeholder : ""; + quill.root.dataset.placeholder = placeholder && !window.isReadOnly ? placeholder : ""; } function getContentsFromHTML(html) { @@ -427,9 +413,7 @@ function revertIndentInlineToClass(html) { var indentRegex = /style="margin-left: ([0-9]+)em;"/gi; return html.replace(indentRegex, replaceIndentRegex); function replaceIndentRegex(match) { - return match - .replace('style="margin-left: ', 'class="ql-indent-') - .replace('em;"', '"'); + return match.replace('style="margin-left: ', 'class="ql-indent-').replace('em;"', '"'); } } @@ -450,18 +434,13 @@ function validate(forceUpdate) { var newValidations = []; if (window.allowImages) { if (!window.connectedSystem) { - newValidations.push( - getTranslation("validationImageStorageConnectedSystemEmpty") - ); + newValidations.push(getTranslation("validationImageStorageConnectedSystemEmpty")); } } if (size > window.quillMaxSize && !window.isReadOnly) { newValidations.push(getTranslation("validationContentTooBig")); } - if ( - forceUpdate || - !(newValidations.toString() === window.currentValidations.toString()) - ) { + if (forceUpdate || !(newValidations.toString() === window.currentValidations.toString())) { Appian.Component.setValidations(newValidations); } window.currentValidations = newValidations; @@ -514,9 +493,7 @@ function doesBase64ImageExist(contents) { // -- This check returning true means it needs to go through the Connected System & get its source replaced function isImageNewBase64(image) { const base64ImgSrcRegex = /^data:/g; - return ( - base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading") - ); + return base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading"); } function buildCssSelector(format) { @@ -586,11 +563,7 @@ function uploadBase64Img(imageSelector) { base64: base64Str, }; - return Appian.Component.invokeClientApi( - window.connectedSystem, - CLIENT_API_FRIENDLY_NAME, - payload - ) + return Appian.Component.invokeClientApi(window.connectedSystem, CLIENT_API_FRIENDLY_NAME, payload) .then(handleClientApiResponseForBase64) .then(function (docURL) { return docURL; @@ -622,10 +595,7 @@ function outputUploadedImages() { function getBrowserAndVersion() { var ua = navigator.userAgent, tem, - M = - ua.match( - /(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i - ) || []; + M = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; if (/trident/i.test(M[1])) { tem = /\brv[ :]+(\d+)/g.exec(ua) || []; return "IE " + (tem[1] || ""); diff --git a/cp/richTextFieldWithTables/v1/index.js b/cp/richTextFieldWithTables/v1/index.js index caffa6f..d24354b 100644 --- a/cp/richTextFieldWithTables/v1/index.js +++ b/cp/richTextFieldWithTables/v1/index.js @@ -90,15 +90,7 @@ const ALLOWED_TAGS = [ "td", "a", ]; -const ALLOWED_ATTRIBUTES = [ - "src", - "style", - "color", - "href", - "target", - "colspan", - "rowspan", -]; +const ALLOWED_ATTRIBUTES = ["src", "style", "color", "href", "target", "colspan", "rowspan"]; const ALLOWED_STYLE_ATTRIBUTES = [ "font-size", "background-color", @@ -182,18 +174,14 @@ function buildEditor() { if (!isReadOnly()) { // 72px is arbitrarily determined based on the height of the toolbar var height = - window.allParameters.height === "auto" - ? "auto" - : parseInt(window.allParameters.height) - 72; + window.allParameters.height === "auto" ? "auto" : parseInt(window.allParameters.height) - 72; // Code for the insertable items button var insertableItemsFiltered = []; if (window.allParameters.insertableItems) { - insertableItemsFiltered = window.allParameters.insertableItems.filter( - function (i) { - return i.label && i.value; - } - ); + insertableItemsFiltered = window.allParameters.insertableItems.filter(function (i) { + return i.label && i.value; + }); } var insertableItemsButton = function (context) { var ui = $.summernote.ui; @@ -233,17 +221,7 @@ function buildEditor() { // Note, list of available buttons can be found here: https://summernote.org/deep-dive/#custom-toolbar-popover ["group0", ["style"]], ["group1", ["fontsize"]], - [ - "group2", - [ - "bold", - "italic", - "underline", - "strikethrough", - "superscript", - "subscript", - ], - ], + ["group2", ["bold", "italic", "underline", "strikethrough", "superscript", "subscript"]], ["group3", ["forecolor", "backcolor"]], ["group4", ["ol", "ul"]], ["group5", ["paragraph", "table"]], @@ -358,9 +336,7 @@ function buildEditor() { */ function isImageNewBase64(image) { const base64ImgSrcRegex = /^data:/g; - return ( - base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading") - ); + return base64ImgSrcRegex.test(image.src) && !image.classList.contains("loading"); } function uploadBase64Img(imageSelector) { @@ -414,11 +390,7 @@ function uploadBase64Img(imageSelector) { base64: base64Str, }; - return Appian.Component.invokeClientApi( - window.connectedSystem, - CLIENT_API_FRIENDLY_NAME, - payload - ) + return Appian.Component.invokeClientApi(window.connectedSystem, CLIENT_API_FRIENDLY_NAME, payload) .then(handleClientApiResponseForBase64) .then(function (docURL) { return docURL; @@ -434,9 +406,7 @@ function returnDisplayParams() { var displayParams = {}; for (var i = 0; i < DISPLAY_PARAMS.length; i++) { var param = DISPLAY_PARAMS[i]; - displayParams[param] = !window.allParameters - ? "" - : window.allParameters[param]; + displayParams[param] = !window.allParameters ? "" : window.allParameters[param]; } return displayParams; } @@ -466,10 +436,7 @@ function setAppianValue() { outputUploadedImages(); var newSaveOutValue = cleanHtml(getEditorContents()); // Always save-out unless the new value we would be saving out matches the last value we saved out - if ( - window.lastSaveOutValue !== newSaveOutValue && - !doesBase64ImageExist() - ) { + if (window.lastSaveOutValue !== newSaveOutValue && !doesBase64ImageExist()) { Appian.Component.saveValue("richText", newSaveOutValue); window.lastSaveOutValue = newSaveOutValue; } @@ -568,9 +535,7 @@ function setDynamicCss() { // STANDARD tableBorderWidth = "1px"; } - cssStyles.push( - "table, td, th, tr {border-width: " + tableBorderWidth + " !important}" - ); + cssStyles.push("table, td, th, tr {border-width: " + tableBorderWidth + " !important}"); // set styles styleEl.innerHTML = cssStyles.join("\n"); @@ -615,18 +580,13 @@ function validate(forceUpdate) { var maxSize = window.allParameters.maxSize || MAX_SIZE_DEFAULT; if (window.allowImages) { if (!window.connectedSystem) { - newValidations.push( - getTranslation("validationImageStorageConnectedSystemEmpty") - ); + newValidations.push(getTranslation("validationImageStorageConnectedSystemEmpty")); } } if (!isReadOnly() && getEditorContents().length > maxSize) { newValidations.push(getTranslation("validationContentTooBig")); } - if ( - forceUpdate || - newValidations.toString() !== window.currentValidations.toString() - ) { + if (forceUpdate || newValidations.toString() !== window.currentValidations.toString()) { Appian.Component.setValidations(newValidations); } window.currentValidations = newValidations; @@ -722,12 +682,9 @@ function cleanHtml(html, isPartialHtml) { if ($1 === "style") { // Step 4: Remove all unnecessary HTML style attributes // Test this Regex here: https://regexr.com/64gqb - return $0.replace( - /([\w-]+): ?(?:[^;]|")*?;? ?(?=[^;]*:|")/g, - function ($0, $1) { - return ALLOWED_STYLE_ATTRIBUTES.indexOf($1) > -1 ? $0 : ""; - } - ); + return $0.replace(/([\w-]+): ?(?:[^;]|")*?;? ?(?=[^;]*:|")/g, function ($0, $1) { + return ALLOWED_STYLE_ATTRIBUTES.indexOf($1) > -1 ? $0 : ""; + }); } else { return $0; } @@ -747,11 +704,7 @@ function cleanHtml(html, isPartialHtml) { // Test this Regex here: https://regexr.com/64iom out = out.replace(/(.*?)<\/a>/g, function ($0, $1, $2) { // Test this Regex here: https://regexr.com/6blub - return $1.match( - /^(?:[A-Za-z0-9+\-.]+:)?(?:https:\/\/|file:\/\/|mailto:).*$/g - ) - ? $0 - : $2; + return $1.match(/^(?:[A-Za-z0-9+\-.]+:)?(?:https:\/\/|file:\/\/|mailto:).*$/g) ? $0 : $2; }); // Step 6: Remove any HTML comments diff --git a/cp/tests/helpers/browserScriptTransform.js b/cp/tests/helpers/browserScriptTransform.js new file mode 100644 index 0000000..280d228 --- /dev/null +++ b/cp/tests/helpers/browserScriptTransform.js @@ -0,0 +1,103 @@ +/** + * Custom Jest transform for browser script files. + * + * These source files are plain browser scripts (not modules). They declare + * functions and constants at the top level. Jest wraps require()'d files + * in a module function scope, so those declarations become module-local. + * + * This transform appends a module.exports block that exposes all the + * declared functions and constants, making them accessible to tests + * while still being instrumented by Jest for coverage. + */ + +"use strict"; + +const RICH_TEXT_WITH_TABLES_EXPORTS = ` + +// ── Auto-appended by browserScriptTransform for testing ── +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + cleanHtml: typeof cleanHtml !== 'undefined' ? cleanHtml : undefined, + readClipboard: typeof readClipboard !== 'undefined' ? readClipboard : undefined, + handleImagePasteFromFile: typeof handleImagePasteFromFile !== 'undefined' ? handleImagePasteFromFile : undefined, + isInternetExplorer: typeof isInternetExplorer !== 'undefined' ? isInternetExplorer : undefined, + isSummernoteActive: typeof isSummernoteActive !== 'undefined' ? isSummernoteActive : undefined, + isImageNewBase64: typeof isImageNewBase64 !== 'undefined' ? isImageNewBase64 : undefined, + doesBase64ImageExist: typeof doesBase64ImageExist !== 'undefined' ? doesBase64ImageExist : undefined, + isTextPresent: typeof isTextPresent !== 'undefined' ? isTextPresent : undefined, + debounce: typeof debounce !== 'undefined' ? debounce : undefined, + debounceOnChange: typeof debounceOnChange !== 'undefined' ? debounceOnChange : undefined, + getTranslation: typeof getTranslation !== 'undefined' ? getTranslation : undefined, + returnDisplayParams: typeof returnDisplayParams !== 'undefined' ? returnDisplayParams : undefined, + haveDisplayParamsChanged: typeof haveDisplayParamsChanged !== 'undefined' ? haveDisplayParamsChanged : undefined, + validate: typeof validate !== 'undefined' ? validate : undefined, + isReadOnly: typeof isReadOnly !== 'undefined' ? isReadOnly : undefined, + setDynamicCss: typeof setDynamicCss !== 'undefined' ? setDynamicCss : undefined, + setA11yCss: typeof setA11yCss !== 'undefined' ? setA11yCss : undefined, + buildEditor: typeof buildEditor !== 'undefined' ? buildEditor : undefined, + setEditorContents: typeof setEditorContents !== 'undefined' ? setEditorContents : undefined, + getEditorContents: typeof getEditorContents !== 'undefined' ? getEditorContents : undefined, + setAppianValue: typeof setAppianValue !== 'undefined' ? setAppianValue : undefined, + outputUploadedImages: typeof outputUploadedImages !== 'undefined' ? outputUploadedImages : undefined, + ALLOWED_TAGS: typeof ALLOWED_TAGS !== 'undefined' ? ALLOWED_TAGS : undefined, + ALLOWED_ATTRIBUTES: typeof ALLOWED_ATTRIBUTES !== 'undefined' ? ALLOWED_ATTRIBUTES : undefined, + ALLOWED_STYLE_ATTRIBUTES: typeof ALLOWED_STYLE_ATTRIBUTES !== 'undefined' ? ALLOWED_STYLE_ATTRIBUTES : undefined, + MAX_SIZE_DEFAULT: typeof MAX_SIZE_DEFAULT !== 'undefined' ? MAX_SIZE_DEFAULT : undefined, + DISPLAY_PARAMS: typeof DISPLAY_PARAMS !== 'undefined' ? DISPLAY_PARAMS : undefined, + }; +} +`; + +const RICH_TEXT_FIELD_EXPORTS = ` + +// ── Auto-appended by browserScriptTransform for testing ── +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + revertIndentInlineToClass: typeof revertIndentInlineToClass !== 'undefined' ? revertIndentInlineToClass : undefined, + getContentsFromHTML: typeof getContentsFromHTML !== 'undefined' ? getContentsFromHTML : undefined, + getHTMLFromContents: typeof getHTMLFromContents !== 'undefined' ? getHTMLFromContents : undefined, + debounce: typeof debounce !== 'undefined' ? debounce : undefined, + buildCssSelector: typeof buildCssSelector !== 'undefined' ? buildCssSelector : undefined, + getBrowserAndVersion: typeof getBrowserAndVersion !== 'undefined' ? getBrowserAndVersion : undefined, + returnParentWindowUrl: typeof returnParentWindowUrl !== 'undefined' ? returnParentWindowUrl : undefined, + doesBase64ImageExist: typeof doesBase64ImageExist !== 'undefined' ? doesBase64ImageExist : undefined, + isImageNewBase64: typeof isImageNewBase64 !== 'undefined' ? isImageNewBase64 : undefined, + getTranslation: typeof getTranslation !== 'undefined' ? getTranslation : undefined, + translateToolbar: typeof translateToolbar !== 'undefined' ? translateToolbar : undefined, + validate: typeof validate !== 'undefined' ? validate : undefined, + getSize: typeof getSize !== 'undefined' ? getSize : undefined, + isTextPresent: typeof isTextPresent !== 'undefined' ? isTextPresent : undefined, + updateUsageBar: typeof updateUsageBar !== 'undefined' ? updateUsageBar : undefined, + updateColors: typeof updateColors !== 'undefined' ? updateColors : undefined, + handleDisplay: typeof handleDisplay !== 'undefined' ? handleDisplay : undefined, + updateValue: typeof updateValue !== 'undefined' ? updateValue : undefined, + initializeCopyPaste: typeof initializeCopyPaste !== 'undefined' ? initializeCopyPaste : undefined, + uploadBase64Img: typeof uploadBase64Img !== 'undefined' ? uploadBase64Img : undefined, + outputUploadedImages: typeof outputUploadedImages !== 'undefined' ? outputUploadedImages : undefined, + availableFormats: typeof availableFormats !== 'undefined' ? availableFormats : undefined, + availableFormatsFlattened: typeof availableFormatsFlattened !== 'undefined' ? availableFormatsFlattened : undefined, + defaultFormats: typeof defaultFormats !== 'undefined' ? defaultFormats : undefined, + MAX_SIZE_DEFAULT: typeof MAX_SIZE_DEFAULT !== 'undefined' ? MAX_SIZE_DEFAULT : undefined, + }; +} +`; + +module.exports = { + process(sourceText, sourcePath) { + let code = sourceText; + + if ( + sourcePath.includes("richTextFieldWithTables") && + sourcePath.endsWith("index.js") + ) { + code += RICH_TEXT_WITH_TABLES_EXPORTS; + } else if ( + sourcePath.includes("richTextField") && + sourcePath.endsWith("index.js") + ) { + code += RICH_TEXT_FIELD_EXPORTS; + } + + return { code }; + }, +}; diff --git a/cp/tests/helpers/loadRichTextField.js b/cp/tests/helpers/loadRichTextField.js new file mode 100644 index 0000000..d5c0563 --- /dev/null +++ b/cp/tests/helpers/loadRichTextField.js @@ -0,0 +1,165 @@ +/** + * Test helper: loads richTextField/v1/index.js in a sandboxed + * context with mocked browser globals so Jest can instrument it for coverage. + * + * Returns an object with all the functions declared in the source file. + */ + +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +function loadModule() { + // Load i18n first + const i18nSource = fs.readFileSync( + path.resolve(__dirname, "../../richTextField/v1/i18n.js"), + "utf8", + ); + + // Load the main source + const mainSource = fs.readFileSync( + path.resolve(__dirname, "../../richTextField/v1/index.js"), + "utf8", + ); + + // Mock Quill + const mockQuillInstance = { + on: jest.fn(), + root: Object.assign(document.createElement("div"), { + dataset: {}, + addEventListener: jest.fn(), + }), + getContents: jest.fn(() => ({ ops: [] })), + setContents: jest.fn(), + getText: jest.fn(() => "\n"), + getLength: jest.fn(() => 1), + enable: jest.fn(), + format: jest.fn(), + container: document.createElement("div"), + update: jest.fn(), + }; + + const MockQuill = jest.fn(() => mockQuillInstance); + MockQuill.import = jest.fn(() => { + // Return a mock class for blots/block, formats/link, attributors, etc. + const MockBlot = function () {}; + MockBlot.tagName = "p"; + MockBlot.PROTOCOL_WHITELIST = ["http", "https"]; + return MockBlot; + }); + MockQuill.register = jest.fn(); + MockQuill.sources = { USER: "user" }; + + // Set up DOM elements the source expects + const parentContainer = document.createElement("div"); + parentContainer.id = "parent-container"; + document.body.appendChild(parentContainer); + + const quillToolbar = document.createElement("div"); + quillToolbar.id = "quill-toolbar"; + parentContainer.appendChild(quillToolbar); + + const quillContainer = document.createElement("div"); + quillContainer.id = "quill-container"; + parentContainer.appendChild(quillContainer); + + const sizeBar = document.createElement("div"); + sizeBar.id = "sizeBar"; + parentContainer.appendChild(sizeBar); + + const usageBar = document.createElement("div"); + usageBar.id = "usageBar"; + sizeBar.appendChild(usageBar); + + const usageMessage = document.createElement("div"); + usageMessage.id = "usageMessage"; + sizeBar.appendChild(usageMessage); + + // Build sandbox + const sandbox = { + window: global.window, + document: global.document, + navigator: global.navigator, + console: global.console, + setTimeout: global.setTimeout, + clearTimeout: global.clearTimeout, + setInterval: global.setInterval, + clearInterval: global.clearInterval, + FileReader: global.FileReader, + Array: global.Array, + Object: global.Object, + JSON: global.JSON, + Math: global.Math, + parseInt: global.parseInt, + RegExp: global.RegExp, + String: global.String, + Error: global.Error, + Promise: global.Promise, + + Quill: MockQuill, + + Appian: { + getLocale: jest.fn(() => "en-US"), + getAccentColor: jest.fn(() => "#1a73e8"), + Component: { + onNewValue: jest.fn(), + saveValue: jest.fn(), + setValidations: jest.fn(), + invokeClientApi: jest.fn(() => Promise.resolve({ payload: {} })), + }, + }, + }; + + vm.createContext(sandbox); + + // Execute i18n + vm.runInContext(i18nSource, sandbox, { + filename: "richTextField/v1/i18n.js", + }); + + // Execute main source with exports wrapper + const wrappedSource = ` + ${mainSource} + + var __exports = { + revertIndentInlineToClass: revertIndentInlineToClass, + getContentsFromHTML: getContentsFromHTML, + getHTMLFromContents: getHTMLFromContents, + debounce: debounce, + buildCssSelector: buildCssSelector, + getBrowserAndVersion: getBrowserAndVersion, + returnParentWindowUrl: returnParentWindowUrl, + doesBase64ImageExist: doesBase64ImageExist, + isImageNewBase64: isImageNewBase64, + getTranslation: getTranslation, + translateToolbar: translateToolbar, + validate: validate, + getSize: getSize, + isTextPresent: isTextPresent, + updateUsageBar: updateUsageBar, + updateColors: updateColors, + handleDisplay: handleDisplay, + updateValue: updateValue, + initializeCopyPaste: initializeCopyPaste, + uploadBase64Img: uploadBase64Img, + outputUploadedImages: outputUploadedImages, + availableFormats: availableFormats, + availableFormatsFlattened: availableFormatsFlattened, + defaultFormats: defaultFormats, + MAX_SIZE_DEFAULT: MAX_SIZE_DEFAULT, + }; + `; + + vm.runInContext(wrappedSource, sandbox, { + filename: "richTextField/v1/index.js", + }); + + return { + exports: sandbox.__exports, + sandbox, + mockQuillInstance, + MockQuill, + }; +} + +module.exports = { loadModule }; diff --git a/cp/tests/helpers/loadRichTextFieldWithTables.js b/cp/tests/helpers/loadRichTextFieldWithTables.js new file mode 100644 index 0000000..de2b71c --- /dev/null +++ b/cp/tests/helpers/loadRichTextFieldWithTables.js @@ -0,0 +1,117 @@ +/** + * Test helper: loads richTextFieldWithTables/v1/index.js via require() + * so Jest instruments it for coverage. + * + * Sets up all required browser globals (Appian, $, summernote, translations) + * before requiring the source file. After require(), all function declarations + * from the source are available on `global` because Jest wraps scripts in a + * function scope — but we can access them via the module's local scope. + * + * Strategy: We set up globals, then require() the source. Since the source + * uses function declarations (hoisted), they exist in the module scope. + * We then use a small eval trick to extract them. + * + * Actually, the cleanest approach: modify jest config to treat these as + * scripts, OR, just set up globals and require the file — Jest's module + * wrapper means function declarations are module-scoped, not global. + * So we need to extract them. + * + * Final approach: We'll read the source, append module.exports, and + * require a generated temp file. But that's fragile. + * + * ACTUAL final approach: Set up globals, then use require() on the source. + * Jest wraps it in (function(module, exports, require, ...) { }). + * Function declarations are scoped to that wrapper. We can't access them. + * + * THE REAL SOLUTION: Add a jest transform that appends exports to the source. + */ + +// This module is used by the custom jest transform. See jest.config.js. +// It just provides the mock setup function. + +function setupGlobals() { + // Mock summernote jQuery object + const mockSummernote = { + summernote: jest.fn(function (cmd) { + if (cmd === "isEmpty") return true; + if (cmd === "code") return ""; + if (cmd === "destroy") return; + return mockSummernote; + }), + on: jest.fn(), + }; + + const jQueryMock = jest.fn(function (selector) { + if (selector === "#summernote") return mockSummernote; + const chainable = { + hide: jest.fn().mockReturnThis(), + show: jest.fn().mockReturnThis(), + css: jest.fn().mockReturnThis(), + removeAttr: jest.fn().mockReturnThis(), + find: jest.fn().mockReturnValue({ on: jest.fn() }), + on: jest.fn().mockReturnThis(), + html: jest.fn().mockReturnValue(""), + attr: jest.fn().mockReturnThis(), + summernote: mockSummernote.summernote, + }; + chainable[0] = document.createElement("div"); + chainable.length = 1; + return chainable; + }); + jQueryMock.summernote = { + ui: { + buttonGroup: jest.fn(() => ({ render: jest.fn() })), + button: jest.fn(), + dropdown: jest.fn(), + }, + }; + + global.$ = jQueryMock; + global.english_translations = { + textHeaderLarge: "Large Header", + textHeaderMedium: "Medium Header", + textHeaderSmall: "Small Header", + textNormal: "Normal Text", + validationImageStorageConnectedSystemEmpty: + "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", + default: "Default", + }; + global.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é.", + 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", + }; + global.Appian = { + getLocale: jest.fn(() => "en-US"), + getAccentColor: jest.fn(() => "#1a73e8"), + Component: { + onNewValue: jest.fn(), + saveValue: jest.fn(), + setValidations: jest.fn(), + invokeClientApi: jest.fn(() => Promise.resolve({ payload: {} })), + }, + }; + + // DOM element + if (!document.getElementById("summernote")) { + const div = document.createElement("div"); + div.id = "summernote"; + document.body.appendChild(div); + } + + return { mockSummernote, jQueryMock }; +} + +module.exports = { setupGlobals }; diff --git a/cp/tests/helpers/setupGlobals.js b/cp/tests/helpers/setupGlobals.js new file mode 100644 index 0000000..124e3b8 --- /dev/null +++ b/cp/tests/helpers/setupGlobals.js @@ -0,0 +1,198 @@ +/** + * Jest setup file: establishes browser globals needed by the source files. + * Runs before each test suite. + */ + +// ── Summernote / jQuery mocks ──────────────────────────────────────── + +const mockSummernote = { + summernote: jest.fn(function (cmd, value) { + if (cmd === "isEmpty") return true; + if (cmd === "code" && value === undefined) return ""; + if (cmd === "destroy") return; + return mockSummernote; + }), + on: jest.fn(), +}; + +const jQueryMock = jest.fn(function (selector) { + if (selector === "#summernote") return mockSummernote; + if (typeof selector === "string" && selector.startsWith("<")) { + // Creating elements like $("") + const tag = selector.replace(/[<>]/g, ""); + const el = document.createElement(tag); + const wrapper = { + attr: jest.fn(function () { + return wrapper; + }), + 0: el, + length: 1, + }; + return wrapper; + } + const chainable = { + hide: jest.fn().mockReturnThis(), + show: jest.fn().mockReturnThis(), + css: jest.fn().mockReturnThis(), + removeAttr: jest.fn().mockReturnThis(), + find: jest.fn().mockReturnValue({ on: jest.fn() }), + on: jest.fn().mockReturnThis(), + html: jest.fn().mockReturnValue(""), + attr: jest.fn().mockReturnThis(), + summernote: mockSummernote.summernote, + }; + chainable[0] = document.createElement("div"); + chainable.length = 1; + return chainable; +}); +jQueryMock.summernote = { + ui: { + buttonGroup: jest.fn(() => ({ render: jest.fn() })), + button: jest.fn(), + dropdown: jest.fn(), + }, +}; + +global.$ = jQueryMock; +global.__mockSummernote = mockSummernote; + +// ── Quill mock ─────────────────────────────────────────────────────── + +const quillRoot = document.createElement("div"); +const mockQuillInstance = { + on: jest.fn(), + root: quillRoot, + getContents: jest.fn(() => ({ ops: [] })), + setContents: jest.fn(), + getText: jest.fn(() => "\n"), + getLength: jest.fn(() => 1), + enable: jest.fn(), + format: jest.fn(), + container: document.createElement("div"), + update: jest.fn(), +}; + +const MockQuill = jest.fn(() => mockQuillInstance); +MockQuill.import = jest.fn(() => { + const MockBlot = function () {}; + MockBlot.tagName = "p"; + MockBlot.PROTOCOL_WHITELIST = ["http", "https"]; + return MockBlot; +}); +MockQuill.register = jest.fn(); +MockQuill.sources = { USER: "user" }; + +global.Quill = MockQuill; +global.__mockQuillInstance = mockQuillInstance; + +// ── Translation globals ────────────────────────────────────────────── + +global.english_translations = { + tooltipStyle: "Style", + textHeaderLarge: "Large Header", + textHeaderMedium: "Medium Header", + textHeaderSmall: "Small Header", + textNormal: "Normal Text", + tooltipSize: "Size", + sizeSmall: "Small", + sizeStandard: "Standard", + sizeMedium: "Medium", + sizeLarge: "Large", + tooltipBold: "Bold (%+B)", + tooltipItalics: "Italics (%+I)", + tooltipUnderline: "Underline (%+U)", + tooltipStrikethrough: "Strikethrough", + tooltipSuperscript: "Superscript", + tooltipSubscript: "Subscript", + tooltipFontColor: "Font Color", + tooltipBackgroundColor: "Background Color", + tooltipAddLink: "Add Link (%+K)", + tooltipAddImage: "Add Image", + tooltipAlignment: "Alignment", + tooltipUnindent: "Unindent (%+[)", + tooltipIndent: "Indent (%+])", + tooltipNumberedList: "Numbered List (%+Shift+7)", + tooltipBulletedList: "Bulleted List (%+Shift+8)", + tooltipRemoveFormatting: "Remove Formatting", + usageBarUsed: "used", + validationImageStorageConnectedSystemEmpty: + "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", + default: "Default", +}; + +global.french_translations = { + tooltipStyle: "Style", + textHeaderLarge: "Grand en-tête", + textHeaderMedium: "Moyen en-tête", + textHeaderSmall: "Petit en-tête", + textNormal: "Texte normal", + tooltipSize: "Taille de police", + sizeSmall: "Petite", + sizeStandard: "Standard", + sizeMedium: "Moyenne", + sizeLarge: "Grande", + tooltipBold: "Gras (%+B)", + tooltipItalics: "Italique (%+I)", + tooltipUnderline: "Souligné (%+U)", + tooltipStrikethrough: "Barré", + tooltipSuperscript: "Exposant", + tooltipSubscript: "Indice", + tooltipFontColor: "Couleur du texte", + tooltipBackgroundColor: "Couleur d'arrière-plan", + tooltipAddLink: "Ajouter un lien hypertexte (%+K)", + tooltipAddImage: "Ajouter une image", + tooltipAlignment: "Alignement", + tooltipUnindent: "Diminuer le retrait (%+[)", + tooltipIndent: "Augmenter le retrait (%+])", + tooltipNumberedList: "Numérotation (%+Shift+7)", + tooltipBulletedList: "Liste à puces (%+Shift+8)", + tooltipRemoveFormatting: "Retirer le formatage", + usageBarUsed: "utilisé", + validationImageStorageConnectedSystemEmpty: + "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é", + default: "Réglage par défaut", +}; + +// ── Appian SDK mock ────────────────────────────────────────────────── + +global.Appian = { + getLocale: jest.fn(() => "en-US"), + getAccentColor: jest.fn(() => "#1a73e8"), + Component: { + onNewValue: jest.fn(), + saveValue: jest.fn(), + setValidations: jest.fn(), + invokeClientApi: jest.fn(() => Promise.resolve({ payload: {} })), + }, +}; + +// ── DOM elements needed by richTextField ───────────────────────────── + +function ensureElement(id, parent) { + if (!document.getElementById(id)) { + const el = document.createElement("div"); + el.id = id; + (parent || document.body).appendChild(el); + return el; + } + return document.getElementById(id); +} + +const parentContainer = ensureElement("parent-container"); +const quillToolbar = ensureElement("quill-toolbar", parentContainer); +const quillContainer = ensureElement("quill-container", parentContainer); +const sizeBar = ensureElement("sizeBar", parentContainer); +ensureElement("usageBar", sizeBar); +ensureElement("usageMessage", sizeBar); + +// ── DOM element needed by richTextFieldWithTables ──────────────────── + +ensureElement("summernote"); diff --git a/cp/tests/richTextField/utilities.test.js b/cp/tests/richTextField/utilities.test.js new file mode 100644 index 0000000..e36abdb --- /dev/null +++ b/cp/tests/richTextField/utilities.test.js @@ -0,0 +1,238 @@ +/** + * Tests for utility functions from richTextField/v1/index.js + */ + +const { + revertIndentInlineToClass, + debounce, + buildCssSelector, + getBrowserAndVersion, + returnParentWindowUrl, + doesBase64ImageExist, + isImageNewBase64, + getTranslation, + availableFormats, + availableFormatsFlattened, + defaultFormats, + MAX_SIZE_DEFAULT, +} = require("../../richTextField/v1/index.js"); + +describe("revertIndentInlineToClass", () => { + test("converts single indent to class", () => { + expect( + revertIndentInlineToClass('

text

'), + ).toBe('

text

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

text

'), + ).toBe('

text

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

text

'), + ).toBe('

text

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

one

three

'; + const expected = + '

one

three

'; + expect(revertIndentInlineToClass(input)).toBe(expected); + }); + + test("does not modify non-indent styles", () => { + const input = '

text

'; + expect(revertIndentInlineToClass(input)).toBe(input); + }); + + test("does not modify margin-left with non-em units", () => { + const input = '

text

'; + expect(revertIndentInlineToClass(input)).toBe(input); + }); + + test("handles empty html", () => { + expect(revertIndentInlineToClass("")).toBe(""); + }); +}); + +describe("buildCssSelector", () => { + test("builds selector for bold", () => { + expect(buildCssSelector("bold")).toBe("button.ql-bold,span.ql-bold"); + }); + + test("builds selector for italic", () => { + expect(buildCssSelector("italic")).toBe("button.ql-italic,span.ql-italic"); + }); + + test("builds selector for image", () => { + expect(buildCssSelector("image")).toBe("button.ql-image,span.ql-image"); + }); +}); + +describe("debounce", () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + test("executes function after delay", () => { + const fn = jest.fn(); + const debounced = debounce(fn, 500); + debounced(); + expect(fn).not.toHaveBeenCalled(); + jest.advanceTimersByTime(500); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("cancels previous call on rapid invocation", () => { + const fn = jest.fn(); + const debounced = debounce(fn, 200); + debounced(); + debounced(); + debounced(); + jest.advanceTimersByTime(200); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); + +describe("getBrowserAndVersion", () => { + // The real function reads navigator.userAgent directly, not a parameter. + // We need to mock navigator.userAgent for each test. + const originalUserAgent = navigator.userAgent; + + afterEach(() => { + Object.defineProperty(navigator, "userAgent", { + value: originalUserAgent, + configurable: true, + }); + }); + + test("detects Chrome", () => { + Object.defineProperty(navigator, "userAgent", { + value: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + configurable: true, + }); + // Also need to mock appName/appVersion for the fallback path + expect(getBrowserAndVersion()).toBe("Chrome 120"); + }); + + 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", + configurable: true, + }); + expect(getBrowserAndVersion()).toBe("Firefox 121"); + }); + + 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", + configurable: true, + }); + expect(getBrowserAndVersion()).toBe("IE 11"); + }); + + test("detects Edge (legacy)", () => { + Object.defineProperty(navigator, "userAgent", { + value: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36 Edge/18", + configurable: true, + }); + expect(getBrowserAndVersion()).toBe("Edge 18"); + }); + + test("detects Safari", () => { + Object.defineProperty(navigator, "userAgent", { + value: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17 Safari/605.1.15", + configurable: true, + }); + expect(getBrowserAndVersion()).toBe("Safari 17"); + }); +}); + +describe("returnParentWindowUrl", () => { + // The real function reads document.referrer directly + test("extracts base URL from referrer with /suite/ path", () => { + Object.defineProperty(document, "referrer", { + value: "https://site-appiancloud.com/suite/sites/mysite/page/home", + configurable: true, + }); + expect(returnParentWindowUrl()).toBe("https://site-appiancloud.com"); + }); + + test("extracts base URL from referrer with trailing slash", () => { + Object.defineProperty(document, "referrer", { + value: "https://site-appiancloud.com/", + configurable: true, + }); + expect(returnParentWindowUrl()).toBe("https://site-appiancloud.com"); + }); + + test("extracts base URL from referrer without trailing slash", () => { + Object.defineProperty(document, "referrer", { + value: "https://site-appiancloud.com", + configurable: true, + }); + expect(returnParentWindowUrl()).toBe("https://site-appiancloud.com"); + }); +}); + +describe("isImageNewBase64", () => { + test("returns true for new base64 image", () => { + const img = document.createElement("img"); + img.src = "data:image/png;base64,abc123"; + expect(isImageNewBase64(img)).toBe(true); + }); + + test("returns false when loading class is present", () => { + const img = document.createElement("img"); + img.src = "data:image/png;base64,abc123"; + img.classList.add("loading"); + expect(isImageNewBase64(img)).toBe(false); + }); + + test("returns false for http URL source", () => { + const img = document.createElement("img"); + img.src = "https://example.com/photo.jpg"; + expect(isImageNewBase64(img)).toBe(false); + }); +}); + +describe("getTranslation", () => { + test("returns English tooltip", () => { + expect(getTranslation("tooltipBold")).toBe("Bold (%+B)"); + }); + + test("returns usage bar text", () => { + expect(getTranslation("usageBarUsed")).toBe("used"); + }); +}); + +describe("format configuration", () => { + test("availableFormatsFlattened contains all formats", () => { + expect(availableFormatsFlattened).toContain("header"); + expect(availableFormatsFlattened).toContain("bold"); + expect(availableFormatsFlattened).toContain("image"); + expect(availableFormatsFlattened).toContain("list"); + }); + + test("defaultFormats excludes image", () => { + expect(defaultFormats).not.toContain("image"); + }); + + test("defaultFormats includes all other formats", () => { + expect(defaultFormats).toContain("header"); + expect(defaultFormats).toContain("bold"); + expect(defaultFormats).toContain("link"); + }); + + test("MAX_SIZE_DEFAULT is 10000", () => { + expect(MAX_SIZE_DEFAULT).toBe(10000); + }); +}); diff --git a/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js b/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js new file mode 100644 index 0000000..f2e2023 --- /dev/null +++ b/cp/tests/richTextFieldWithTables/cleanHtml.security.test.js @@ -0,0 +1,208 @@ +/** + * Security-focused tests for cleanHtml() + */ + +const { cleanHtml } = require("../../richTextFieldWithTables/v1/index.js"); + +describe("cleanHtml - XSS prevention", () => { + test("strips "); + expect(result).not.toContain(" with attributes", () => { + const result = cleanHtml( + '', + ); + expect(result).not.toContain(" { + const result = cleanHtml('

text

'); + expect(result).not.toContain("onclick"); + expect(result).toContain("text"); + }); + + test("strips onload handler", () => { + const result = cleanHtml('

text

'); + expect(result).not.toContain("onload"); + }); + + test("strips onmouseover handler", () => { + const result = cleanHtml('

text

'); + expect(result).not.toContain("onmouseover"); + }); + + test("strips onerror handler", () => { + const result = cleanHtml('

text

'); + expect(result).not.toContain("onerror"); + }); + + test("strips onfocus handler", () => { + const result = cleanHtml('

text

'); + expect(result).not.toContain("onfocus"); + }); + + test("strips javascript: protocol in links", () => { + const result = cleanHtml( + 'click', + ); + expect(result).not.toContain("javascript:"); + expect(result).toContain("click"); + }); + + test("strips data: protocol in links", () => { + const result = cleanHtml( + 'click', + ); + expect(result).toContain("click"); + }); + + test("strips ', + ); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml( + '', + ); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml(''); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml( + '
', + ); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml( + '', + ); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml(''); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml( + '', + ); + expect(result).not.toContain(" tags", () => { + const result = cleanHtml("x"); + expect(result).not.toContain(" { + test("handles deeply nested allowed tags", () => { + const input = + "

deep

"; + const result = cleanHtml(input); + expect(result).toContain("deep"); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + }); + + test("handles empty tags", () => { + expect(cleanHtml("

")).toBe("

"); + expect(cleanHtml("")).toBe(""); + }); + + test("handles self-closing br", () => { + expect(cleanHtml("
")).toBe("
"); + }); + + test("handles multiple consecutive br tags", () => { + expect(cleanHtml("


")).toBe("


"); + }); + + test("handles mixed allowed and disallowed tags", () => { + const input = "

keep

also keep
"; + const result = cleanHtml(input); + expect(result).toContain("

keep

"); + expect(result).toContain("also keep"); + expect(result).not.toContain(" { + const longText = "a".repeat(10000); + const input = "

" + longText + "

"; + const result = cleanHtml(input); + expect(result).toBe(input); + }); + + test("handles unicode content", () => { + const input = "

日本語テスト 🎉 émojis

"; + const result = cleanHtml(input); + expect(result).toContain("日本語テスト"); + expect(result).toContain("🎉"); + }); + + test("handles HTML entities", () => { + const input = "

& < > "

"; + const result = cleanHtml(input); + expect(result).toContain("&"); + expect(result).toContain("<"); + }); + + test("preserves link with target attribute", () => { + const input = 'link'; + const result = cleanHtml(input); + expect(result).toContain('target="_blank"'); + expect(result).toContain('href="https://example.com"'); + }); +}); + +describe("cleanHtml - paste from various sources", () => { + test("cleans paste from Microsoft Word", () => { + const wordPaste = + '

Title

'; + const result = cleanHtml(wordPaste, true); + expect(result).not.toContain("MsoNormal"); + expect(result).not.toContain("Calibri"); + expect(result).not.toContain("")).toBe("

text

"); + }); + + test("removes multi-word comments", () => { + expect(cleanHtml("

text

")).toBe( + "

text

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

a

b

")).toBe( + "

a

b

", + ); + }); + }); + + // ── Step 7: Whitespace trimming ──────────────────────────────────── + describe("Step 7: Whitespace trimming", () => { + test("trims leading and trailing whitespace from HTML", () => { + expect(cleanHtml("

text

")).toBe("

text

"); + }); + + test("trims leading whitespace from raw text (wraps in p)", () => { + const result = cleanHtml(" hello "); + expect(result).toBe("

hello

"); + }); + + test("collapses multiple spaces to single space", () => { + expect(cleanHtml("

too many spaces

")).toBe( + "

too many spaces

", + ); + }); + }); + + // ── Complex / real-world scenarios ───────────────────────────────── + describe("real-world paste scenarios", () => { + test("cleans Word paste with MsoNormal and extra attributes", () => { + const wordHtml = + '

Title

'; + const result = cleanHtml(wordHtml, true); + expect(result).toContain("Title"); + expect(result).not.toContain("MsoNormal"); + expect(result).toContain("font-size"); + expect(result).not.toContain("font-family"); + }); + + test("cleans Google Docs paste with spans and data attributes", () => { + const googleHtml = + 'text'; + const result = cleanHtml(googleHtml, true); + expect(result).toContain(" { + const input = "

formatted

"; + expect(cleanHtml(input)).toBe(input); + }); + + test("handles table with styles", () => { + const input = + '
cell
'; + const result = cleanHtml(input); + expect(result).toContain(""); + expect(result).toContain("width"); + expect(result).toContain("text-align"); + }); + + test("strips XSS attempts in tags", () => { + const xss = "

safe

"; + const result = cleanHtml(xss); + expect(result).not.toContain("