From 29a50af3c4f35f3ce60c96298b0341143be76834 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 16 Dec 2024 08:44:17 +0200 Subject: [PATCH] Refactor tests so that the stx tests also run in NodeJS It would be nice to have tests split up into modules, but this causes all kinds of headaches with running the tests in both the browser and NodeJS. So we're keeping them in one file for now. --- karma.conf.js | 1 - src/builder.js | 2 +- src/shims.js | 32 +++- src/types/builder.d.ts | 2 +- src/types/shims.d.ts | 4 + src/types/stanza.d.ts | 2 +- tests/stx.js | 276 ------------------------------- tests/tests.js | 363 ++++++++++++++++++++++++++++++++++++----- 8 files changed, 354 insertions(+), 328 deletions(-) delete mode 100644 tests/stx.js diff --git a/karma.conf.js b/karma.conf.js index 3d7b6dc..a405574 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -13,7 +13,6 @@ module.exports = function (config) { 'node_modules/sinon/pkg/sinon.js', 'dist/strophe.umd.js', 'tests/tests.js', - 'tests/stx.js' ], // list of files to exclude diff --git a/src/builder.js b/src/builder.js index 255d7ee..3a1bc35 100644 --- a/src/builder.js +++ b/src/builder.js @@ -110,7 +110,7 @@ class Builder { * @example const stanza = Builder.fromString(''); */ static fromString(str) { - const el = toElement(str); + const el = toElement(str, true); const b = new Builder(''); b.#nodeTree = el; return b; diff --git a/src/shims.js b/src/shims.js index 512365b..3ed346e 100644 --- a/src/shims.js +++ b/src/shims.js @@ -26,7 +26,8 @@ function getWebSocketImplementation() { if (typeof globalThis.WebSocket === 'undefined') { try { return require('ws'); - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "ws" package to use Strophe in nodejs.'); } } @@ -34,6 +35,29 @@ function getWebSocketImplementation() { } export const WebSocket = getWebSocketImplementation(); +/** + * Retrieves the XMLSerializer implementation for the current environment. + * + * In browser environments, it uses the built-in XMLSerializer. + * In Node.js environments, it attempts to load the 'jsdom' package + * to create a compatible XMLSerializer. + */ +function getXMLSerializerImplementation() { + if (typeof globalThis.XMLSerializer === 'undefined') { + let JSDOM; + try { + JSDOM = require('jsdom').JSDOM; + // eslint-disable-next-line no-unused-vars + } catch (e) { + throw new Error('You must install the "ws" package to use Strophe in nodejs.'); + } + const dom = new JSDOM(''); + return dom.window.XMLSerializer; + } + return globalThis.XMLSerializer; +} +export const XMLSerializer = getXMLSerializerImplementation(); + /** * DOMParser * https://w3c.github.io/DOM-Parsing/#the-domparser-interface @@ -52,7 +76,8 @@ function getDOMParserImplementation() { let JSDOM; try { JSDOM = require('jsdom').JSDOM; - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "jsdom" package to use Strophe in nodejs.'); } const dom = new JSDOM(''); @@ -75,7 +100,8 @@ export function getDummyXMLDOMDocument() { let JSDOM; try { JSDOM = require('jsdom').JSDOM; - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "jsdom" package to use Strophe in nodejs.'); } const dom = new JSDOM(''); diff --git a/src/types/builder.d.ts b/src/types/builder.d.ts index 26ce78a..a848ec8 100644 --- a/src/types/builder.d.ts +++ b/src/types/builder.d.ts @@ -66,7 +66,7 @@ declare class Builder { * Creates a new Builder object from an XML string. * @param {string} str * @returns {Builder} - * @example const stanza = Builder.fromString(''); + * @example const stanza = Builder.fromString(''); */ static fromString(str: string): Builder; /** diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index 187d054..77f01d1 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -14,6 +14,10 @@ export const WebSocket: { readonly CLOSING: 2; readonly CLOSED: 3; } | typeof import("ws"); +export const XMLSerializer: { + new (): XMLSerializer; + prototype: XMLSerializer; +}; export const DOMParser: { new (): DOMParser; prototype: DOMParser; diff --git a/src/types/stanza.d.ts b/src/types/stanza.d.ts index 6f19ac0..bb3b8ea 100644 --- a/src/types/stanza.d.ts +++ b/src/types/stanza.d.ts @@ -40,7 +40,7 @@ export class Stanza extends Builder { * const pres = stx` * * dnd - * ${unsafeXML(status) + * ${unsafeXML(status)} * `; * connection.send(pres); */ diff --git a/tests/stx.js b/tests/stx.js deleted file mode 100644 index 91e0586..0000000 --- a/tests/stx.js +++ /dev/null @@ -1,276 +0,0 @@ -QUnit.module('The stx tagged template literal'); - -test('can be used to create Stanza objects that are equivalent to Builder objects', (assert) => { - let templateStanza = stx` - - - - - - JC - - - - - JC - - - - - - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); - - templateStanza = stx` - - Thrice the brinded cat hath mew'd. - - `; - - // prettier-ignore - builderStanza = $msg({ - from: 'coven@chat.shakespeare.lit/firstwitch', - id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', - to: 'hecate@shakespeare.lit/broom', - type: 'groupchat', - }).c('body').t("Thrice the brinded cat hath mew'd.").up() - .c('delay', { xmlns: 'urn:xmpp:delay', from: 'coven@chat.shakespeare.lit', stamp: '2002-10-13T23:58:37Z' }); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); - - templateStanza = stx` - - - - - `; - - // prettier-ignore - builderStanza = $pres({ - from: 'hag66@shakespeare.lit/pda', - id: 'n13mt3l', - to: 'coven@chat.shakespeare.lit/thirdwitch', - }).c('x', { xmlns: 'http://jabber.org/protocol/muc' }) - .c('history', { maxchars: '65000' }); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('can be nested recursively', (assert) => { - let templateStanza = stx` - - - - ${[ - stx` - - JC - - `, - stx` - - JC - - - - - `, - ]} - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('can have nested Builder objects', (assert) => { - // prettier-ignore - let templateStanza = stx` - - - - ${[ - new Strophe.Builder('item', { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC"), - new Strophe.Builder('item', { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }), - ]} - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('escape the values passed in to them', (assert) => { - const status = ''; - const templateStanza = stx` - - ${status} - `; - - assert.equal( - templateStanza.tree().querySelector('status').innerHTML, - '<script>alert("p0wned")</script>' - ); -}); - -const EMPTY_TEXT_REGEX = /\s*\n\s*/; -const serializer = new XMLSerializer(); - -/** - * @param {Element|Builder|Stanza} el - */ -function stripEmptyTextNodes(el) { - if (el instanceof Strophe.Builder || el instanceof Strophe.Stanza) { - el = el.tree(); - } - - let n; - const text_nodes = []; - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, (node) => { - if (node.parentElement.nodeName.toLowerCase() === 'body') { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }); - while ((n = walker.nextNode())) text_nodes.push(n); - text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(/** @type {Text} */ (n).data) && n.parentElement.removeChild(n)); - - return el; -} - -/** - * Given two XML or HTML elements, determine if they're equal - * @param {Element} actual - * @param {Element} expected - * @returns {Boolean} - */ -function isEqualNode(actual, expected) { - actual = stripEmptyTextNodes(actual); - expected = stripEmptyTextNodes(expected); - - let isEqual = actual.isEqualNode(expected); - - if (!isEqual) { - // XXX: This is a hack. - // When creating two XML elements, one via DOMParser, and one via - // createElementNS (or createElement), then "isEqualNode" doesn't match. - // - // For example, in the following code `isEqual` is false: - // ------------------------------------------------------ - // const a = document.createElementNS('foo', 'div'); - // a.setAttribute('xmlns', 'foo'); - // - // const b = (new DOMParser()).parseFromString('
', 'text/xml').firstElementChild; - // const isEqual = a.isEqualNode(div); // false - // - // The workaround here is to serialize both elements to string and then use - // DOMParser again for both (via xmlHtmlNode). - // - // This is not efficient, but currently this is only being used in tests. - // - const { xmlHtmlNode } = Strophe; - const actual_string = serializer.serializeToString(actual); - const expected_string = serializer.serializeToString(expected); - isEqual = - actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string)); - } - return isEqual; -} diff --git a/tests/tests.js b/tests/tests.js index d28c6e7..8135bff 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -1,49 +1,5 @@ /*global globalThis, Strophe, $iq, $msg, $build, $pres, QUnit, stx */ - -/** - * Mock xhr, provides getAllResponseHeaders function. - * @param status - * @param readyState - * @param responseText - */ -function XHR(status, readyState, responseText) { - this.status = status; - this.readyState = readyState; - this.responseText = responseText; - this.getAllResponseHeaders = () => null; -} - -class SASLFoo extends Strophe.SASLMechanism { - constructor() { - super('FOO', false, 10); - } - - static get name() { - return 'FOO'; - } -} - -function makeRequest(stanza) { - const req = new Strophe.Request(stanza, () => {}); - req.getResponse = function () { - const env = new Strophe.Builder('env', { type: 'mock' }).tree(); - env.appendChild(stanza); - return env; - }; - return req; -} - -const _sessionStorage = {}; - -if (!globalThis.sessionStorage) { - Object.defineProperty(globalThis, 'sessionStorage', { - value: { - setItem: (key, value) => (_sessionStorage[key] = value), - getItem: (key) => _sessionStorage[key] ?? null, - removeItem: (key) => delete _sessionStorage[key], - }, - }); -} +const serializer = new Strophe.shims.XMLSerializer(); const { test } = QUnit; @@ -193,6 +149,11 @@ test('Strophe.Connection.prototype.send() accepts Builders (#27)', (assert) => { timeoutStub.restore(); }); +test('The fromString static method', (assert) => { + const stanza = Strophe.Builder.fromString(''); + assert.equal(isEqualNode(stanza, $pres({ from: 'juliet@example.com/chamber' })), true); +}); + QUnit.module('Strophe.Connection options'); test('withCredentials can be set on the XMLHttpRequest object', (assert) => { @@ -1043,3 +1004,315 @@ test('nextValidRid is called after connection reset', (assert) => { assert.equal(spy.calledWith(4294967295), true, 'The RID was valid'); Math.random.restore(); }); + +QUnit.module('The stx tagged template literal'); + +test('can be used to create Stanza objects that are equivalent to Builder objects', (assert) => { + let templateStanza = stx` + + + + + + JC + + + + + JC + + + + + + + + `; + + // prettier-ignore + let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); + + templateStanza = stx` + + Thrice the brinded cat hath mew'd. + + `; + + // prettier-ignore + builderStanza = $msg({ + from: 'coven@chat.shakespeare.lit/firstwitch', + id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', + to: 'hecate@shakespeare.lit/broom', + type: 'groupchat', + }).c('body').t("Thrice the brinded cat hath mew'd.").up() + .c('delay', { xmlns: 'urn:xmpp:delay', from: 'coven@chat.shakespeare.lit', stamp: '2002-10-13T23:58:37Z' }); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); + + templateStanza = stx` + + + + + `; + + // prettier-ignore + builderStanza = $pres({ + from: 'hag66@shakespeare.lit/pda', + id: 'n13mt3l', + to: 'coven@chat.shakespeare.lit/thirdwitch', + }).c('x', { xmlns: 'http://jabber.org/protocol/muc' }) + .c('history', { maxchars: '65000' }); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('can be nested recursively', (assert) => { + const templateStanza = stx` + + + + ${[ + stx` + + JC + + `, + stx` + + JC + + + + + `, + ]} + + + `; + + // prettier-ignore + const builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('can have nested Builder objects', (assert) => { + // prettier-ignore + const templateStanza = stx` + + + + ${[ + new Strophe.Builder('item', { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC"), + new Strophe.Builder('item', { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }), + ]} + + + `; + + // prettier-ignore + const builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('escape the values passed in to them', (assert) => { + const status = ''; + const templateStanza = stx` + + ${status} + `; + + assert.equal( + templateStanza.tree().querySelector('status').innerHTML, + '<script>alert("p0wned")</script>' + ); +}); + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +function stripEmptyTextNodes(element) { + const childNodes = Array.from(element.childNodes ?? []); + childNodes.forEach((node) => { + if (node.nodeType === TEXT_NODE && !node.nodeValue.trim()) { + element.removeChild(node); + } else if (node.nodeType === ELEMENT_NODE) { + stripEmptyTextNodes(node); // Recursively call for child elements + } + }); + return element; +} + +/** + * Given two XML or HTML elements, determine if they're equal + * @param {Strophe.Stanza|Strophe.Builder} actual + * @param {Strophe.Stanza|Strophe.Builder} expected + * @returns {Boolean} + */ +function isEqualNode(actual, expected) { + actual = stripEmptyTextNodes(actual.tree()); + expected = stripEmptyTextNodes(expected.tree()); + + let isEqual = actual.isEqualNode(expected); + + if (!isEqual) { + // XXX: This is a hack. + // When creating two XML elements, one via DOMParser, and one via + // createElementNS (or createElement), then "isEqualNode" doesn't match. + // + // For example, in the following code `isEqual` is false: + // ------------------------------------------------------ + // const a = document.createElementNS('foo', 'div'); + // a.setAttribute('xmlns', 'foo'); + // + // const b = (new DOMParser()).parseFromString('
', 'text/xml').firstElementChild; + // const isEqual = a.isEqualNode(div); // false + // + // The workaround here is to serialize both elements to string and then use + // DOMParser again for both (via xmlHtmlNode). + // + // This is not efficient, but currently this is only being used in tests. + // + const { xmlHtmlNode } = Strophe; + const actual_string = serializer.serializeToString(actual); + const expected_string = serializer.serializeToString(expected); + isEqual = + actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string)); + } + return isEqual; +} + +/** + * Mock xhr, provides getAllResponseHeaders function. + * @param status + * @param readyState + * @param responseText + */ +function XHR(status, readyState, responseText) { + this.status = status; + this.readyState = readyState; + this.responseText = responseText; + this.getAllResponseHeaders = () => null; +} + +class SASLFoo extends Strophe.SASLMechanism { + constructor() { + super('FOO', false, 10); + } + + static get name() { + return 'FOO'; + } +} + +function makeRequest(stanza) { + const req = new Strophe.Request(stanza, () => {}); + req.getResponse = function () { + const env = new Strophe.Builder('env', { type: 'mock' }).tree(); + env.appendChild(stanza); + return env; + }; + return req; +} + +const _sessionStorage = {}; + +if (!globalThis.sessionStorage) { + Object.defineProperty(globalThis, 'sessionStorage', { + value: { + setItem: (key, value) => (_sessionStorage[key] = value), + getItem: (key) => _sessionStorage[key] ?? null, + removeItem: (key) => delete _sessionStorage[key], + }, + }); +}