Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ jobs:
with:
useLockFile: false

- name: Install chai
run: npm install chai

- name: Install Solved Plugin
run: npm install ./nodebb-plugin-solved

Expand Down
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,6 @@
"imagesloaded": "5.0.0",
"ioredis": "5.6.1",
"ipaddr.js": "2.2.0",
"jquery": "3.7.1",
"jquery-deserialize": "2.0.0",
"jquery-form": "4.3.0",
"jquery-serializeobject": "1.0.0",
Expand All @@ -101,12 +100,14 @@
"multiparty": "4.2.3",
"nconf": "0.13.0",
"nodebb-plugin-2factor": "7.5.10",
"nodebb-plugin-anonymous-button": "file:nodebb-plugin-anonymous-button",
"nodebb-plugin-composer-default": "10.2.51",
"nodebb-plugin-dbsearch": "6.2.19",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.2.1",
"nodebb-plugin-mentions": "4.7.6",
"nodebb-plugin-solved": "file:nodebb-plugin-solved",
"nodebb-plugin-spam-be-gone": "2.3.2",
"nodebb-plugin-summarizer": "file:plugins/nodebb-plugin-thread-summarizer",
"nodebb-plugin-thread-summarizer": "file:nodebb-plugin-thread-summarizer",
Expand Down Expand Up @@ -165,6 +166,7 @@
"@commitlint/cli": "19.8.1",
"@commitlint/config-angular": "19.8.1",
"@eslint/js": "9.26.0",
"chai": "^6.2.0",
"@stylistic/eslint-plugin": "^5.4.0",
"coveralls": "3.1.1",
"eslint-config-nodebb": "1.1.5",
Expand All @@ -174,7 +176,9 @@
"husky": "8.0.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jsdom": "26.1.0",
"jquery": "^3.7.1",
"jsdom": "^26.1.0",
"jsdom-global": "^3.0.2",
"lint-staged": "16.0.0",
"mocha": "^10.8.2",
"mocha-lcov-reporter": "1.3.0",
Expand Down
222 changes: 222 additions & 0 deletions test/anonymous-button.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
const { expect } = require('chai');
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');

describe('Anonymous Button Plugin Test', function () {
let window, document, $;
let hooksCallbacks = {};

beforeEach(function () {
// Create a fresh JSDOM instance for each test
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'http://localhost',
runScripts: 'dangerously',
resources: 'usable',
});

window = dom.window;
document = window.document;

// Mock jQuery
$ = function (selector) {
// Handle HTML string
if (typeof selector === 'string' && selector.trim().startsWith('<')) {
const temp = document.createElement('div');
temp.innerHTML = selector;
return $(Array.from(temp.childNodes));
}

// Handle CSS selector string
if (typeof selector === 'string') {
return $(document.querySelectorAll(selector));
}

// Handle array-like (NodeList or Array)
if ((window.NodeList && selector instanceof window.NodeList) || Array.isArray(selector)) {
const elements = Array.from(selector);
const obj = {
length: elements.length,
each: function (callback) {
elements.forEach((el, i) => callback.call(el, i, el));
return obj;
},
on: function (event, handler) {
elements.forEach(el => el.addEventListener(event, handler));
return obj;
},
click: function () {
elements.forEach(el => el.click());
return obj;
},
toggleClass: function (className, toggle) {
elements.forEach(el => {
if (!el.classList) return; // FIX: prevent TypeError
if (toggle === undefined) el.classList.toggle(className);
else if (toggle) el.classList.add(className);
else el.classList.remove(className);
});
return obj;
},
hasClass: function (className) {
return elements.length > 0 && elements[0].classList?.contains(className);
},
addClass: function (className) {
elements.forEach(el => el.classList?.add(className));
return obj;
},
removeClass: function (className) {
elements.forEach(el => el.classList?.remove(className));
return obj;
},
append: function (content) {
elements.forEach(el => {
if (typeof content === 'string') el.insertAdjacentHTML('beforeend', content);
else if (content.jquery) content.each((i, c) => el.appendChild(c));
});
return obj;
},
attr: function (name, value) {
if (value === undefined) return elements.length ? elements[0].getAttribute(name) : undefined;
elements.forEach(el => el.setAttribute(name, value));
return obj;
},
find: function (sel) {
const found = [];
elements.forEach(el => found.push(...el.querySelectorAll(sel)));
return $(found);
},
html: function (content) {
if (content === undefined) return elements.length ? elements[0].innerHTML : '';
elements.forEach(el => el.innerHTML = content);
return obj;
},
replaceWith: function (content) {
elements.forEach(el => {
const temp = document.createElement('div');
temp.innerHTML = content;
el.replaceWith(...temp.childNodes);
});
return obj;
},
text: function () {
return elements.length ? elements[0].textContent : '';
},
first: function () {
return elements.length ? $(elements[0]) : $([]);
},
parent: function () {
const parents = [];
elements.forEach(el => { if (el.parentElement) parents.push(el.parentElement); });
return $(parents);
},
jquery: true,
};

elements.forEach((el, i) => obj[i] = el);
return obj;
}

// Single DOM node
if (selector && selector.nodeType) return $([selector]);

// Fallback
return $(document.querySelectorAll(selector));
};

// Reset hooks
hooksCallbacks = {};

// Mock NodeBB hooks
const hooks = {
on: function (hookName, callback) {
if (!hooksCallbacks[hookName]) hooksCallbacks[hookName] = [];
hooksCallbacks[hookName].push(callback);
},
};

// AMD-style require mock
const amdRequire = function (deps, factory) {
if (Array.isArray(deps) && typeof factory === 'function') {
const mocks = { hooks, api: {} };
const modules = deps.map(d => mocks[d] || {});
factory(...modules);
} else {
return require.apply(this, arguments);
}
};

// Store mocks in window
window.require = amdRequire;
window.$ = $;
window.hooksCallbacks = hooksCallbacks;
});

// Helper function to load plugin
function loadPlugin() {
const pluginCode = fs.readFileSync(
path.join(__dirname, '../nodebb-plugin-anonymous-button/public/js/anonymous-button.js'),
'utf-8'
);
const script = window.document.createElement('script');
script.textContent = pluginCode;
window.document.head.appendChild(script);
}

it('prints to terminal', function () {
console.log('[TEST] Anonymous Button plugin test running!');
expect(true).to.equal(true);
});

it('creates and initializes the plugin hooks', function () {
loadPlugin();
expect(hooksCallbacks['action:ajaxify.end']).to.be.an('array');
expect(hooksCallbacks['action:ajaxify.end'].length).to.be.at.least(1);
});

it('adds the anonymous button when ajaxify.end hook is triggered', function () {
loadPlugin();
hooksCallbacks['action:ajaxify.end']?.forEach(cb => cb());
const button = document.querySelector('#anon-toggle-floating');
expect(button).to.not.be.null;
expect(button.textContent).to.include('Anon');
});

it('button toggles class when clicked', function () {
loadPlugin();
hooksCallbacks['action:ajaxify.end']?.forEach(cb => cb());
const button = document.querySelector('#anon-toggle-floating');
expect(button).to.not.be.null;

expect(button.classList.contains('btn-secondary')).to.be.true;
expect(button.classList.contains('btn-success')).to.be.false;

// Clicking should now toggle properly without TypeError
button.click();
expect(button.classList.contains('btn-success')).to.be.true;
expect(button.classList.contains('btn-secondary')).to.be.false;

button.click();
expect(button.classList.contains('btn-secondary')).to.be.true;
expect(button.classList.contains('btn-success')).to.be.false;
});

it('registers filter:composer.submit hook', function () {
loadPlugin();
expect(hooksCallbacks['filter:composer.submit']).to.be.an('array');
expect(hooksCallbacks['filter:composer.submit'].length).to.be.at.least(1);
});

it('registers action:composer.submit hook', function () {
loadPlugin();
expect(hooksCallbacks['action:composer.submit']).to.be.an('array');
expect(hooksCallbacks['action:composer.submit'].length).to.be.at.least(1);
});

it('button exists only once even when ajaxify.end fires multiple times', function () {
loadPlugin();
for (let i = 0; i < 3; i++) hooksCallbacks['action:ajaxify.end']?.forEach(cb => cb());
const buttons = document.querySelectorAll('#anon-toggle-floating');
expect(buttons.length).to.equal(1);
});
});