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], + }, + }); +}