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