From d3af58a6e0aeaf63c732ab3efdd993ae30226eef Mon Sep 17 00:00:00 2001 From: paul Date: Mon, 9 Oct 2023 15:25:19 -0700 Subject: [PATCH 1/6] Full implementation of xhr.responseType, rigorous test and a perf improvement. --- lib/XMLHttpRequest.js | 275 +++++++++++-- tests/server.js | 11 +- tests/test-perf.js | 236 ++++++++++++ tests/test-request-protocols-binary-data.js | 79 ++-- tests/test-response-type.js | 402 ++++++++++++++++++++ tests/test-sync-response.js | 86 ++++- 6 files changed, 1027 insertions(+), 62 deletions(-) create mode 100644 tests/test-perf.js create mode 100644 tests/test-response-type.js diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index e21eea8..b8cd1b2 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -134,6 +134,17 @@ function XMLHttpRequest(opts) { this.status = null; this.statusText = null; + // xhr.responseType is supported: + // When responseType is 'text' or '', self.responseText will be utf8 decoded text. + // When responseType is 'json', self.responseText initially will be utf8 decoded text, + // which is then JSON parsed into self.response. + // When responseType is 'document', self.responseText initially will be utf8 decoded text, + // which is then parsed by npmjs package '@xmldom/xmldom' into a DOM object self.responseXML. + // When responseType is 'arraybuffer', self.response is an ArrayBuffer. + // When responseType is 'blob', self.response is a Blob. + // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + this.responseType = ""; /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' or 'document' */ + /** * Private methods */ @@ -158,6 +169,89 @@ function XMLHttpRequest(opts) { return (method && forbiddenRequestMethods.indexOf(method) === -1); }; + /** + * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according + * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . + * However, bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer is larger + * than the useable region in bufTotal. This means that a new copy of bufTotal would need to be + * created to get the correct ArrayBuffer. Instead, do the concat by hand to create the right + * sized ArrayBuffer in the first place. + * + * The return type is Uint8Array, + * because often Buffer will have Buffer.length < Buffer.buffer.byteLength. + * + * @param {Array} bufferArray + * @returns {Uint8Array} + */ + var concat = function(bufferArray) { + let length = 0, offset = 0; + for (let k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = new Uint8Array(length); + for (let k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; + }; + + /** + * When xhr.responseType === 'arraybuffer', xhr.response must have type ArrayBuffer according + * to section 3.6.9 of https://xhr.spec.whatwg.org/#the-response-attribute . + * However, buf = Buffer.from(str) often has byteOffset > 0, so buf.buffer is larger than the + * usable region in buf. This means that a new copy of buf would need to be created to get the + * correct arrayBuffer. Instead, do it by hand to create the right sized ArrayBuffer in the + * first place. + * + * @param {string} str + * @returns {Buffer} + */ + var stringToBuffer = function(str) { + const ab = new ArrayBuffer(str.length) + const buf = Buffer.from(ab); + for (let k = 0; k < str.length; k++) + buf[k] = Number(str.charCodeAt(k)); + return buf; + } + + /** + * Given a Buffer buf, check whether buf.buffer.byteLength > buf.length and if so, + * create a new ArrayBuffer whose byteLength is buf.length, containing the bytes. + * of buf. This function shouldn't usually be needed, unless there's a future + * behavior change where buf.buffer.byteLength > buf.length unexpectedly. + * + * @param {Buffer} buf + * @returns {ArrayBuffer} + */ + var checkAndShrinkBuffer = function(buf) { + if (buf.length === buf.buffer.byteLength) + return buf.buffer; + const ab = new ArrayBuffer(buf.length); + const result = Buffer.from(ab); + for (let k = 0; k < buf.length; k++) + result[k] = buf[k]; + return ab; + } + + /** + * Parse HTML string and return a DOM. + * When self.responseType is 'document', users are reuired to install the npmjs module '@xmldom/xmldom'. + * cf. 3.6.11 of https://xhr.spec.whatwg.org/#the-response-attribute . + * + * @param {string} - HTML to be parsed. + * @return {string} - DOM representation. + */ + var html2dom = function(html) { + const DOMParser = require('@xmldom/xmldom').DOMParser; + try { + return new DOMParser().parseFromString(html, 'text/xml'); + } catch(err) { + /** @todo throw appropriate DOMException. */ + } + return ''; + }; + /** * Public methods */ @@ -328,16 +422,17 @@ function XMLHttpRequest(opts) { self.handleError(error, error.errno || -1); } else { self.status = 200; - self.responseText = data.toString('utf8'); - self.response = data; + // Use self.responseType to create the correct self.responseType, self.response, self.XMLDocument. + self.createFileOrSyncResponse(data); setState(self.DONE); } }); } else { try { - this.response = fs.readFileSync(unescape(url.pathname)); - this.responseText = this.response.toString('utf8'); this.status = 200; + const syncData = fs.readFileSync(unescape(url.pathname)); + // Use self.responseType to create the correct self.responseType, self.response, self.XMLDocument. + this.createFileOrSyncResponse(syncData); setState(self.DONE); } catch(e) { this.handleError(e, e.errno || -1); @@ -421,6 +516,8 @@ function XMLHttpRequest(opts) { // Set response var to the response we got back // This is so it remains accessable outside this scope response = resp; + // Collect buffers and concatenate once. + const buffers = []; // Check for redirect // @TODO Prevent looped redirects if (response.statusCode === 302 || response.statusCode === 303 || response.statusCode === 307) { @@ -456,13 +553,34 @@ function XMLHttpRequest(opts) { } setState(self.HEADERS_RECEIVED); + + // When responseType is 'text' or '', self.responseText will be utf8 decoded text. + // When responseType is 'json', self.responseText initially will be utf8 decoded text, + // which is then JSON parsed into self.response. + // When responseType is 'document', self.responseText initially will be utf8 decoded text, + // which is then parsed by npmjs package '@xmldom/xmldom'into a DOM object self.responseXML. + // When responseType is 'arraybuffer', self.response is an ArrayBuffer. + // When responseType is 'blob', self.response is a Blob. + // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute + const isUtf8 = self.responseType === "" || self.responseType === "text" + || self.responseType === "json" || self.responseType === "document"; + if (isUtf8 && response.setEncoding) { + response.setEncoding("utf8"); + } + self.status = response.statusCode; response.on('data', function(chunk) { // Make sure there's some data if (chunk) { - var data = Buffer.from(chunk); - self.response = Buffer.concat([self.response, data]); + if (isUtf8) { + // When responseType is 'text', '', 'json' or 'document', + // then each chunk is already utf8 decoded. + self.responseText += chunk; + } else { + // Otherwise collect the chunk buffers. + buffers.push(chunk); + } } // Don't emit state changes if the connection has been aborted. if (sendFlag) { @@ -475,10 +593,10 @@ function XMLHttpRequest(opts) { // The sendFlag needs to be set before setState is called. Otherwise if we are chaining callbacks // there can be a timing issue (the callback is called and a new call is made before the flag is reset). sendFlag = false; + // Create the correct response for responseType. + self.createResponse(buffers); // Discard the 'end' event if the connection has been aborted setState(self.DONE); - // Construct responseText from response - self.responseText = self.response.toString('utf8'); } }); @@ -516,33 +634,43 @@ function XMLHttpRequest(opts) { fs.writeFileSync(syncFile, "", "utf8"); // The async request the other Node process executes var execString = "var http = require('http'), https = require('https'), fs = require('fs');" + + "function concat(bufferArray) {" + + " let length = 0, offset = 0;" + + " for (let k = 0; k < bufferArray.length; k++)" + + " length += bufferArray[k].length;" + + " const result = Buffer.alloc(length);" + + " for (let k = 0; k < bufferArray.length; k++)" + + " {" + + " result.set(bufferArray[k], offset);" + + " offset += bufferArray[k].length;" + + " }" + + " return result;" + + "};" + "var doRequest = http" + (ssl ? "s" : "") + ".request;" + "var options = " + JSON.stringify(options) + ";" - + "var responseText = '';" + "var responseData = Buffer.alloc(0);" + + "var buffers = [];" + "var req = doRequest(options, function(response) {" - + "response.on('data', function(chunk) {" - + " var data = Buffer.from(chunk);" - + " responseText += data.toString('utf8');" - + " responseData = Buffer.concat([responseData, data]);" - + "});" - + "response.on('end', function() {" - + "fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, text: responseText, data: responseData.toString('base64')}}), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" - + "response.on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" - + "});" + + " response.on('data', function(chunk) {" + + " buffers.push(chunk);" + + " });" + + " response.on('end', function() {" + + " responseData = concat(buffers);" + + " fs.writeFileSync('" + contentFile + "', JSON.stringify({err: null, data: {statusCode: response.statusCode, headers: response.headers, data: responseData.toString('utf8')}}), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + + " response.on('error', function(error) {" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + + " });" + "}).on('error', function(error) {" - + "fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" - + "fs.unlinkSync('" + syncFile + "');" + + " fs.writeFileSync('" + contentFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');" + + " fs.unlinkSync('" + syncFile + "');" + "});" + (data ? "req.write('" + JSON.stringify(data).slice(1,-1).replace(/'/g, "\\'") + "');":"") + "req.end();"; // Start the other Node Process, executing this string var syncProc = spawn(process.argv[0], ["-e", execString]); - var statusText; while(fs.existsSync(syncFile)) { // Wait while the sync file is empty } @@ -557,16 +685,19 @@ function XMLHttpRequest(opts) { self.handleError(errorObj, 503); } else { // If the file returned okay, parse its data and move to the DONE state - self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1"); - var resp = JSON.parse(self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1")); + const resp = JSON.parse(self.responseText); + self.status = resp.data.statusCode; + self.response = stringToBuffer(resp.data.data); + // Use self.responseType to create the correct self.responseType, self.response, self.responseXML. + self.createFileOrSyncResponse(self.response); + // Set up response correctly. response = { statusCode: self.status, headers: resp.data.headers }; - self.responseText = resp.data.text; - self.response = Buffer.from(resp.data.data, 'base64'); - setState(self.DONE, true); + setState(self.DONE); } + } }; @@ -578,6 +709,8 @@ function XMLHttpRequest(opts) { this.status = status || 0; this.statusText = error; this.responseText = error.stack; + this.responseXML = ""; + this.response = Buffer.alloc(0); errorFlag = true; setState(this.DONE); }; @@ -650,6 +783,88 @@ function XMLHttpRequest(opts) { } }; + /** + * Construct the correct form of response, given responseType when in non-file based, asynchronous mode. + * + * When self.responseType is "", "text", "json", "document", self.responseText is a utf8 string. + * When self.responseType is "arraybuffer", "blob", the response is in the buffers parameter, + * an Array of Buffers. Then concat(buffers) is Uint8Array, from which checkAndShrinkBuffer + * extracts the correct sized ArrayBuffer. + * + * @param {Array} buffers + */ + this.createResponse = function(buffers) { + self.responseXML = ''; + switch (self.responseType) { + case "": + case "text": + self.response = Buffer.alloc(0); + break; + case 'json': + self.response = JSON.parse(self.responseText); + self.responseText = ''; + break; + case "document": + const xml = JSON.parse(self.responseText); + self.responseXML = html2dom(xml); + self.responseText = ''; + self.response = Buffer.alloc(0); + break; + default: + self.responseText = ''; + const totalResponse = concat(buffers); + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + self.response = checkAndShrinkBuffer(totalResponse); + if (self.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + self.response = new Blob([self.response]); + } + break; + } + + } + + /** + * Construct the correct form of response, given responseType when in synchronous mode or file based. + * + * The input is the response parameter which is a Buffer. + * When self.responseType is "", "text", "json", "document", + * the input is further refined to be: response.toString('utf8'). + * When self.responseType is "arraybuffer", "blob", + * the input is further refined to be: checkAndShrinkBuffer(response). + * + * @param {Buffer} response + */ + this.createFileOrSyncResponse = function(response) { + self.responseText = ''; + self.responseXML = ''; + switch (self.responseType) { + case "": + case "text": + self.responseText = response.toString('utf8'); + self.response = Buffer.alloc(0); + break; + case 'json': + self.response = JSON.parse(response.toString('utf8')); + break; + case "document": + const xml = JSON.parse(response.toString('utf8')); + self.responseXML = html2dom(xml); + self.response = Buffer.alloc(0); + break; + default: + // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. + // Get the correct sized ArrayBuffer. + self.response = checkAndShrinkBuffer(response); + if (self.responseType === 'blob' && typeof Blob === 'function') { + // Construct the Blob object that contains response. + self.response = new Blob([self.response]); + } + break; + } + } + /** * Changes readyState and calls onreadystatechange. * diff --git a/tests/server.js b/tests/server.js index 74ccf83..3787f77 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,3 +1,4 @@ + var http = require("http"); var server = http.createServer(function (req, res) { @@ -14,10 +15,18 @@ var server = http.createServer(function (req, res) { return res .writeHead(200, {"Content-Type": "application/json"}) .end(JSON.stringify({ foo: "bar" })); - case "/binary": + case "/binary1": return res .writeHead(200, {"Content-Type": "application/octet-stream"}) .end(Buffer.from("Hello world!")); + case "/binary2": { + const ta = new Float32Array([1, 5, 6, 7]); + const buf = Buffer.from(ta.buffer); + const str = buf.toString('binary'); + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(str); + } default: return res .writeHead(404, {"Content-Type": "text/plain"}) diff --git a/tests/test-perf.js b/tests/test-perf.js new file mode 100644 index 0000000..ce128ec --- /dev/null +++ b/tests/test-perf.js @@ -0,0 +1,236 @@ + +/****************************************************************************************** + * This test measurs the elapsed time to download a Float32Array of length 100,000,000. + */ +// @ts-check +'use strict'; + +const http = require("http"); + +const useLocalXHR = true; +const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; +const { XMLHttpRequest } = require(XHRModule); + +const supressConsoleOutput = false; +function log (...args) { + if ( !supressConsoleOutput) + console.debug(...args); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Create a random Float32Array of length N. + * 2) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('storage:', JSON.stringify(storageLength())); +} + +/** + * mini-webserver: Serves up anything uploaded. + * Tested with: + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) + return res + .writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + .end("Not in storage"); + + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(storage[req.url]); + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType = 'arraybuffer') +{ + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads random float32 array array of length 100,000,000. . + * 2) Downloads the float32 array and measures the download elpased time. + */ + +const N = 100 * 1000 * 1000; +const _f32 = createFloat32Array(N); + +const F32 = Buffer.from(_f32.buffer); + +const urlF32 = "http://localhost:8888/F32"; + +const xhr = new XMLHttpRequest(); + +/** + * 1) Upload Float32Array of length N=100,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + */ +async function runTest() { + try { + let r = await upload(xhr, urlF32, F32); // big + log('upload urlF32, F32 ', r); + + log('-----------------------------------------------------------------------------------'); + checkStorage(); // Check what's in the mini-webserver storage. + log('-----------------------------------------------------------------------------------'); + + const _t0 = Date.now(); + let success = true; + const handle = setTimeout(() => { + console.error('Download has taken longer than 5 seconds and hence it has failed!'); + success = false; + }, 5 * 1000) + const ab = await download(xhr, urlF32, 'arraybuffer'); // big + clearTimeout(handle); + console.log(`Download elapsed time:, ${Date.now() - _t0}ms`, ab.byteLength); + console.info(['...waiting to see elapsed time of download...']) + if (!success) + throw new Error("Download has taken far too long!"); + } catch (e) { + console.log('BOOM', e); + } +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); }) + .catch((e) => { console.log("FAILED"); throw e; }) + .finally(() => { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +}, 100); diff --git a/tests/test-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js index 4a3e42c..bf6a08d 100644 --- a/tests/test-request-protocols-binary-data.js +++ b/tests/test-request-protocols-binary-data.js @@ -1,36 +1,67 @@ -var sys = require("util") - , assert = require("assert") - , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest - , xhr; +/** + * Test GET file URL with both async and sync mode. + * Use xhr.responseType = "arraybuffer". + */ -xhr = new XMLHttpRequest(); +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest -xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); - runSync(); - } -}; +const supressConsoleOutput = true; +function log (...args) { + if ( !supressConsoleOutput) + console.debug(...args); +} -// Async var url = "file://" + __dirname + "/testBinaryData"; -xhr.open("GET", url); -xhr.send(); -// Sync -var runSync = function() { +function download (url, isAsync = true) { xhr = new XMLHttpRequest(); + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = 'arraybuffer'; - xhr.onreadystatechange = function() { - if (this.readyState == 4) { - assert.equal("0000 803f 0000 a040 0000 c040 0000 e040", logBinary(this.response.toString('binary'))); - console.log("done"); - } - }; - xhr.open("GET", url, false); - xhr.send(); + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.response); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); } +async function runTest () { + try { + // Async + var ab = await download(url, /*isAsyn*/ true); + var str = Buffer.from(ab).toString('binary'); + var strLog = logBinary(str); + log('async phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data async phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done async phase"); + + // Sync + var abSync = await download(url, /*isAsyn*/ false); + var str = Buffer.from(abSync).toString('binary'); + var strLog = logBinary(str); + log('sync phase', strLog); + if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) + throw new Error(`Failed test-request-protocols-binary-data sync phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); + log("done sync phase"); + } catch (e) { + console.error('FAILED', e); + } +} + +runTest() + .then(() => console.log('PASSED')) + .catch((e) => { console.error('FAILED'); throw e; }); + function logBinary(data) { function log(data, idx) { return data.charCodeAt(idx).toString(16).padStart(2, '0'); diff --git a/tests/test-response-type.js b/tests/test-response-type.js new file mode 100644 index 0000000..ba5c8be --- /dev/null +++ b/tests/test-response-type.js @@ -0,0 +1,402 @@ + +/****************************************************************************************** + * This test validates xhr.responseType as described by: + * sections 3.6.8, 3.6.9, 3.6.10 of https://xhr.spec.whatwg.org/#the-response-attribute + * except xhr.responseType='document' is not yet supported. + * + * 1) Create a simple min-webserver using the node http module. + * 2) Upload 2 different float32 arrays . + * 3) Upload the utf8 encoding of the underlying in-memory representations of 1). + * 4) Upload a stringified JSON object. + * 5) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ +// @ts-check +'use strict'; + +const http = require("http"); + +const useLocalXHR = true; +const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; +const { XMLHttpRequest } = require(XHRModule); + +const supressConsoleOutput = true; +function log (...args) { + if ( !supressConsoleOutput) + console.debug(...args); +} + +var serverProcess; + +/****************************************************************************************** + * This section has various utility functions: + * 1) Convert typed array to binary string identical to underlying in-memory representation. + * 2) Convert string to typed array when the string is the in-memory representation of a Float32Array. + * 3) Display the underlying in-memory representation of the input string data. + * 4) Pause/sleep for t milliseconds. + * 5) Create a random Float32Array of length N. + * 6) Check to see if 2 array-like objects have the same elements. + * 7) Efficiently concatenate the input Array of Buffers. + */ + +/** + * Create a string corresponding to the in-memory representation of typed array ta. + * @param {{ buffer: ArrayBuffer, length: number }} ta + * @returns {string} + */ +function typedArrayToString (ta) { + const u8 = new Uint8Array(ta.buffer); + return u8.reduce((acc, cur) => acc + String.fromCharCode(cur), ""); +} + +/** + * Assumes str is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in str are all <= 0xFF. + * Returns Float32Array corresponding to str. + * + * @param {string} str + * @returns {Float32Array} + */ +function stringToFloat32Array (str) { + const u8 = new Uint8Array(str.length); + for (let k = 0; k < str.length; k++) + u8[k] = Number(str.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Create a random Float32Array of length N. + * @param {number} N + * @returns {Float32Array} + */ +function createFloat32Array (N) { + let ta = new Float32Array(N); + for (let k = 0; k < ta.length; k++) + ta[k] = Math.random(); + return ta; +} + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + +/** + * Efficiently concatenate the input Array of Buffers. + * Why not use Buffer.concat(...) ? + * Because bufTotal = Buffer.concat(...) often has byteOffset > 0, so bufTotal.buffer + * is larger than the useable region in bufTotal. + * @param {Array} bufferArray + * @returns + */ +function concat (bufferArray) { + var length = 0, offset = 0, k; + for (k = 0; k < bufferArray.length; k++) + length += bufferArray[k].length; + const result = Buffer.alloc(length); + for (k = 0; k < bufferArray.length; k++) + { + result.set(bufferArray[k], offset); + offset += bufferArray[k].length; + } + return result; +}; + +/****************************************************************************************** + * This section produces a web server that serves up anything uploaded. + * The uploaded data is stored as values in a storage object, where the keys are the upload url suffixes. + * E.g. storage['/F32'] === Buffer containing the corresponding upload. + */ + +const storage = { ralph: [1,2] }; + +function storageLength () { + const result = {}; + for (const key in storage) + result[key] = storage[key].length; + return result; +} +function checkStorage () { + log('storage:', JSON.stringify(storageLength())); +} + +// Xml doc for testing responseType "document" +const xmlDoc = +'' ++' test' ++' ' ++''; + +/** + * Serves up anything uploaded. + * Tested with: + * const urlF32 = "http://localhost:8888/F32"; + * const urlF32_2 = "http://localhost:8888/F32_2"; + * const urlUtf8 = "http://localhost:8888/Utf8"; + * const urlUtf8_2 = "http://localhost:8888/Utf8_2"; + * const urlJson = "http://localhost:8888/Json"; + * const urlXml = "http://localhost:8888/Xml"; + */ +function createServer() { + serverProcess = http.createServer(function (req, res) { + req.on('error', err => { console.error('request:', err) }); + res.on('error', err => { console.error('response:', err) }); + if (req.method === 'POST') { + const chunks = []; + //req.on('data', chunk => chunks.push(chunk)); + req.on('data', chunk => { + // console.log('foo', chunk.toString('utf8')); + // console.log('bar', JSON.parse(chunk.toString('utf8'))); + // console.log('bar', unescape(chunk.toString('utf8'))); + chunks.push(chunk); + }); + req.on('end', () => { + const u8 = concat(chunks); + storage[req.url] = u8; + // console.log('server end-handler', req.url, u8.length, req.headers); + // console.log(u8.toString('utf8')); + // console.log('-------------------'); + // console.log(xmlDoc); + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(`success:len ${u8.length}`); + }); + } else { + if (!storage[req.url]) + return res + .writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + .end("Not in storage"); + + if (req.url === "/Utf8" || req.url === "/Utf8_2" || req.url === "/Json" || req.url === "/Xml") + return res + .writeHead(200, {"Content-Type": "text/plain; charset=utf8"}) + .end(storage[req.url].toString()); + return res + .writeHead(200, {"Content-Type": "application/octet-stream"}) + .end(storage[req.url]); + } + }).listen(8888); + process.on("SIGINT", function () { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +} +createServer(); + +/****************************************************************************************** + * This section creates: + * 1) An upload function that POSTs using xmlhttprequest-ssl. + * 2) A download function that GETs using xmlhttprequest-ssl and allows sepcifying xhr.responseType. + */ + +function upload(xhr, url, data) { + return new Promise((resolve, reject) => { + xhr.open("POST", url, true); + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + resolve(xhr.responseText); + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.setRequestHeader('Content-Type', 'multipart/form-data'); // Unnecessary. + xhr.send(data); + }); +} + +function download (xhr, url, responseType = 'arraybuffer') +{ + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + +/****************************************************************************************** + * This section: + * 1) Uploads 2 different float32 arrays . + * 2) Uploads the utf8 encoding of the underlying in-memory representations of 1). + * 3) Uploads a stringified JSON object. + * 4) Then these 5 different uploads are downloaded as xhr.reponseType varies over + * [ "text", "", "arraybuffer", "blob", "json" ] + * and then various checks verify that the downloaded content is the same as that uploaded. + */ + +const N = 1 * 1000 * 1000; +const _f32 = createFloat32Array(N); +const _f32_2 = new Float32Array([ 1, 5, 6, 7, 2, 8 ]); + +const F32 = Buffer.from(_f32.buffer); +const F32_2 = Buffer.from(_f32_2.buffer); +const F32Utf8 = Buffer.from(typedArrayToString(_f32), 'utf8'); +const F32Utf8_2 = Buffer.from(typedArrayToString(_f32_2), 'utf8'); + +const urlF32 = "http://localhost:8888/F32"; +const urlF32_2 = "http://localhost:8888/F32_2"; +const urlUtf8 = "http://localhost:8888/Utf8"; +const urlUtf8_2 = "http://localhost:8888/Utf8_2"; +const urlJson = "http://localhost:8888/Json"; +const urlXml = "http://localhost:8888/Xml"; + +const xhr = new XMLHttpRequest(); + +/** + * 1) Upload Float32Array of length N=1,000,000. + * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. + * 2) Convert the Float32Array of 1) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="text" and check the the string length is the same as the + * byteLength of the array in 1). Downloading as "text" decodes the utf8 into the original. + * 3) Upload Float32Array([1, 5, 6, 7, 2, 8]). + * Then download using xhr.responseType="blob", extract the contained arrayBuffer, view it as + * a Float32Aray and check that the contents are identical. + * 4) Convert the Float32Array of 3) into a string, utf8 encode it and upload it. + * Then download using xhr.responseType="" and check the the string length is the same as the + * byteLength of the array in 3). Downloading as "" decodes the utf8 into the original. + * 5) Let testJson be the current mini-webserver storage object: + * e.g. testJson = {ralph:2,'/F32':4000000,'/Utf8':5333575,'/F32_2':24,'/Utf8_2':28,'/Xml':56,'/Json':77} + * Upload JSON.stringify(testJson) and download it using xhr.responseType="json" + * Check that the objects are the same by comparing the strings after calling JSON.stringify. + * 6) Did a test of xhr.responseType="document" using a simple xml example. + */ +async function runTest() { + const type = (o) => { return `type=${o?.constructor?.name}`; }; + try { + let r = await upload(xhr, urlF32, F32); // big + log('upload urlF32, F32 ', r); + r = await upload(xhr, urlUtf8, F32Utf8); // big + log('upload urlUtf8, F32Utf8 ', r); + + r = await upload(xhr, urlF32_2, F32_2); + log('upload urlF32_2, F32_2 ', r); + r = await upload(xhr, urlUtf8_2, F32Utf8_2); + log('upload urlUtf8_2, F32Utf8_2', r); + + r = await upload(xhr, urlXml, JSON.stringify(xmlDoc)); + log('upload:urlXml, xmlDoc ', r); + + const testJson = storageLength(); + r = await upload(xhr, urlJson, JSON.stringify(testJson)); + log('upload:urlJson, storage ', r); + + log('-----------------------------------------------------------------------------------'); + checkStorage(); // Check what's in the mini-webserver storage. + log('-----------------------------------------------------------------------------------'); + + const ab = await download(xhr, urlF32, 'arraybuffer'); // big + const f32 = new Float32Array(ab) + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + + const text1 = await download(xhr, urlUtf8, 'text'); // big + log('download urlUtf8 text', text1.length, type(text1)); + if (f32.byteLength !== text1.length) + throw new Error(`Download from urlUtf8 has incorrect length: ${f32.byteLength} !== ${text1.length}`); + + if (typeof Blob === 'function') { // Check to see if Blob exists. + const blob = await download(xhr, urlF32_2, 'blob'); + const blob_ab = await blob.arrayBuffer(); + const blob_f32 = new Float32Array(blob_ab); + log(`download urlF32_2 blob ${blob_f32.byteLength} ${type(blob)} `, blob_f32); + if (!isEqual(blob_f32, _f32_2)) + throw new Error(`Download from urlF32_2 has incorrect content: ${blob_f32} !== ${_f32_2}`); + } else { + // If Blob doesn't exist (node versions < 18), then use xhr.responseType="arraybuffer". + const ab_2 = await download(xhr, urlF32_2, 'arraybuffer'); + const ab_2_f32 = new Float32Array(ab_2); + log(`download urlF32_2 arraybuf ${ab_2_f32.byteLength} ${type(ab_2)}`, ab_2_f32); + if (!isEqual(ab_2_f32, _f32_2)) + throw new Error(`Download from urlF32_2 has incorrect content: ${ab_2_f32} !== ${_f32_2}`); + } + + const text2 = await download(xhr, urlUtf8_2, ''); + const text2_f32 = stringToFloat32Array(text2); + log('download urlUtf8_2 default', text2.length, type(text2), text2_f32); + if (!isEqual(text2_f32, _f32_2)) + throw new Error(`Download from urlUtf8_2 has incorrect content: ${text2_f32} !== ${_f32_2}`); + + try { + const { XMLSerializer } = require('@xmldom/xmldom'); // Throws when xmldom not installed. + const doc = await download(xhr, urlXml, 'document'); + const serialized = new XMLSerializer().serializeToString(doc); + const html = serialized.replace(/\t/g, ' '); + log(`download urlXml document ${html.length}`, type(doc), html); + if (html !== xmlDoc) + throw new Error(`Download from urlXml has incorrect content:\n ${html} !== ${xmlDoc}`); + } catch (e) { + if (e?.code === 'MODULE_NOT_FOUND') { + console.error('Please install npmjs package @xmldom/xmldom to test xhr.responseType==="document"'); + console.info('XMLHttpRequest.js also needs access to @xmldom/xmldom'); + } else { + throw e; // rethrow + } + } + + const json = await download(xhr, urlJson, 'json'); + log(`download urlJson json ${JSON.stringify(json).length}`, type(json), json); + if (JSON.stringify(json) !== JSON.stringify(testJson)) + throw new Error(`Download from urlJson has incorrect content:\n ${JSON.stringify(json)} !== ${JSON.stringify(testJson)}`); + + } catch (e) { + log('BOOM', e); + throw e; // rethrow + } +} + +/** + * Run the test. + * If runTest() fails, an exception will be thrown. + */ +setTimeout(function () { + runTest() + .then(() => { console.log("PASSED"); }) + .catch((e) => { console.log("FAILED"); throw e; }) + .finally(() => { + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }); +}, 100); diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 4809076..54da8b0 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -1,33 +1,79 @@ +/** + * Test GET http URL with both async and sync mode. + * Use xhr.responseType = "" and "arraybuffer". + */ + var assert = require("assert") , spawn = require('child_process').spawn , XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest , serverProcess; +const supressConsoleOutput = true; +function log (...args) { + if ( !supressConsoleOutput) + console.debug(...args); +} + // Running a sync XHR and a webserver within the same process will cause a deadlock serverProcess = spawn(process.argv[0], [__dirname + "/server.js"], { stdio: 'inherit' }); setTimeout(function () { try { - runTest() + runTest(); + console.log('PASSED'); } catch (e) { - throw e + console.log('FAILED'); + throw e; } finally { serverProcess.kill('SIGINT'); } }, 100); +/** + * Assumes hexStr is the in-memory representation of a Float32Array. + * Relies on the fact that the char codes in hexStr are all <= 0xFF. + * Returns Float32Array corresponding to hexStr. + * + * @param {string} hexStr + * @returns {Float32Array} + */ +function stringToFloat32Array (hexStr) { + const u8 = new Uint8Array(hexStr.length); + for (let k = 0; k < hexStr.length; k++) + u8[k] = Number(hexStr.charCodeAt(k)); + return new Float32Array(u8.buffer); +} + +/** + * Check to see if 2 array-like objects have the same elements. + * @param {{ length: number }} ar1 + * @param {{ length: number }} ar2 + * @returns {boolean} + */ +function isEqual (ar1, ar2) { + if (ar1.length !== ar2.length) + return false; + for (let k = 0; k < ar1.length; k++) + if (ar1[k] !== ar2[k]) + return false; + return true; +} + function runTest() { var xhr = new XMLHttpRequest(); var isSync = false; xhr.onreadystatechange = function () { if (xhr.readyState === 4) { + // xhr.responseText is a 'utf8' string. + var str = xhr.responseText; + log('/text', str); assert.equal(xhr.responseText, "Hello world!"); assert.equal(xhr.getResponseHeader('content-type'), 'text/plain') isSync = true; } } - + xhr.open("GET", "http://localhost:8888/text", false); xhr.send(); @@ -38,16 +84,42 @@ function runTest() { xhr.onreadystatechange = function () { if (xhr.readyState === 4) { - assert.equal(xhr.response.toString(), 'Hello world!'); + // xhr.response is an ArrayBuffer + var str = Buffer.from(xhr.response).toString('utf8'); + log('/binary1', str); + assert.equal(str, 'Hello world!'); assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') isSync = true; } } - xhr.open("GET", "http://localhost:8888/binary", false); + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.responseType = 'arraybuffer'; + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + // xhr.response is an ArrayBuffer + var binaryStr = Buffer.from(xhr.response).toString('binary'); + var f32 = stringToFloat32Array(binaryStr); + log('/binary2', f32); + var answer = new Float32Array([1, 5, 6, 7]); + assert.equal(isEqual(f32, answer), true); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary2", false); + xhr.responseType = 'arraybuffer'; xhr.send(); assert(isSync, "XMLHttpRequest was not synchronous"); - console.log("done"); -} \ No newline at end of file + log("done"); +} From 57339150fb2c970e736766401c5b9e041eb7381d Mon Sep 17 00:00:00 2001 From: paul Date: Mon, 9 Oct 2023 16:08:06 -0700 Subject: [PATCH 2/6] Minor typos... --- tests/test-response-type.js | 2 +- tests/test-sync-response.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test-response-type.js b/tests/test-response-type.js index ba5c8be..498c3b1 100644 --- a/tests/test-response-type.js +++ b/tests/test-response-type.js @@ -1,7 +1,7 @@ /****************************************************************************************** * This test validates xhr.responseType as described by: - * sections 3.6.8, 3.6.9, 3.6.10 of https://xhr.spec.whatwg.org/#the-response-attribute + * section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute * except xhr.responseType='document' is not yet supported. * * 1) Create a simple min-webserver using the node http module. diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 54da8b0..ef1589d 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -121,5 +121,5 @@ function runTest() { assert(isSync, "XMLHttpRequest was not synchronous"); - log("done"); + console.log("done"); } From 1a5edb1fa93aff58a084383f42607ea0be266aa8 Mon Sep 17 00:00:00 2001 From: paul Date: Mon, 9 Oct 2023 16:59:15 -0700 Subject: [PATCH 3/6] Modified test-utf8-tearing.js to incorporate xhr.responseType changes. When the default xhr.responseType we only set xhr.responseText. To get the desired xhr.response we need to separately GET with xhr.responseType='arraybuffer'. --- lib/XMLHttpRequest.js | 1 - tests/test-utf8-tearing.js | 76 ++++++++++++++++++++++++-------------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index b8cd1b2..114fdbd 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -822,7 +822,6 @@ function XMLHttpRequest(opts) { } break; } - } /** diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index cd3d849..b2d1b12 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -167,6 +167,41 @@ const xhr = new XMLHttpRequest(); const url = "http://localhost:8888/binary"; const urlUtf8 = "http://localhost:8888/binaryUtf8"; +function download (xhr, url, responseType = 'arraybuffer') +{ + return new Promise((resolve, reject) => { + xhr.open("GET", url, true); + + xhr.responseType = responseType; + + xhr.onloadend = () => { + if (xhr.status >= 200 && xhr.status < 300) + { + switch (responseType) + { + case "": + case "text": + resolve(xhr.responseText); + break; + case "document": + resolve(xhr.responseXML); + break; + default: + resolve(xhr.response); + break; + } + } + else + { + const errorTxt = `${xhr.status}: ${xhr.statusText}`; + reject(errorTxt); + } + }; + + xhr.send(); + }); +} + /** * Send a GET request to the server. * When isUtf8 is true, assume that xhr.response is already @@ -176,39 +211,26 @@ const urlUtf8 = "http://localhost:8888/binaryUtf8"; * @param {boolean} isUtf8 * @returns {Promise} */ -function Get(url, isUtf8) { - return new Promise((resolve, reject) => { - xhr.open("GET", url, true); - xhr.onloadend = function(event) { - - log('xhr.status:', xhr.status); - - if (xhr.status >= 200 && xhr.status < 300) { - const contentType = xhr.getResponseHeader('content-type'); - assert.equal(contentType, 'application/octet-stream'); +async function Get(url, isUtf8) { + const dataTxt = await download(xhr, url, 'text'); + const ab = await download(xhr, url, 'arraybuffer'); + const data = Buffer.from(ab); - const dataTxt = xhr.responseText; - const data = xhr.response; - assert(dataTxt && data); + assert(dataTxt && data); - log('XHR GET:', contentType, dataTxt.length, data.length, data.toString('utf8').length); - log('XHR GET:', data.constructor.name, dataTxt.constructor.name); + log('XHR GET:', dataTxt.length, data.length, data.toString('utf8').length); + log('XHR GET:', data.constructor.name, dataTxt.constructor.name); - if (isUtf8 && dataTxt.length !== data.toString('utf8').length) - throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); + if (isUtf8 && dataTxt.length !== data.toString('utf8').length) + throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); - const ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); - log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); + const ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); + log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); - if (!checkEnough(ta, f32)) - throw new Error("Unable to correctly reconstitute Float32Array"); + if (!checkEnough(ta, f32)) + throw new Error("Unable to correctly reconstitute Float32Array"); - resolve(ta); - } - reject(new Error(`Request failed: xhr.status ${xhr.status}`)); - } - xhr.send(); - }); + return ta; } /** From e83886572fe7fc09e98aaeb9defcf476c201ae02 Mon Sep 17 00:00:00 2001 From: paul Date: Wed, 23 Oct 2024 14:59:11 -0400 Subject: [PATCH 4/6] Remove document from xhr.responseType. Go back to setting xhr.response as well as xhr.responseText --- lib/XMLHttpRequest.js | 52 +++++++------------------------------ tests/test-sync-response.js | 16 ++++++++++++ 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 114fdbd..18e5a6d 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -138,12 +138,10 @@ function XMLHttpRequest(opts) { // When responseType is 'text' or '', self.responseText will be utf8 decoded text. // When responseType is 'json', self.responseText initially will be utf8 decoded text, // which is then JSON parsed into self.response. - // When responseType is 'document', self.responseText initially will be utf8 decoded text, - // which is then parsed by npmjs package '@xmldom/xmldom' into a DOM object self.responseXML. // When responseType is 'arraybuffer', self.response is an ArrayBuffer. // When responseType is 'blob', self.response is a Blob. // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute - this.responseType = ""; /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' or 'document' */ + this.responseType = ""; /* 'arraybuffer' or 'text' or '' or 'json' or 'blob' */ /** * Private methods @@ -234,24 +232,6 @@ function XMLHttpRequest(opts) { return ab; } - /** - * Parse HTML string and return a DOM. - * When self.responseType is 'document', users are reuired to install the npmjs module '@xmldom/xmldom'. - * cf. 3.6.11 of https://xhr.spec.whatwg.org/#the-response-attribute . - * - * @param {string} - HTML to be parsed. - * @return {string} - DOM representation. - */ - var html2dom = function(html) { - const DOMParser = require('@xmldom/xmldom').DOMParser; - try { - return new DOMParser().parseFromString(html, 'text/xml'); - } catch(err) { - /** @todo throw appropriate DOMException. */ - } - return ''; - }; - /** * Public methods */ @@ -422,7 +402,7 @@ function XMLHttpRequest(opts) { self.handleError(error, error.errno || -1); } else { self.status = 200; - // Use self.responseType to create the correct self.responseType, self.response, self.XMLDocument. + // Use self.responseType to create the correct self.responseType, self.response. self.createFileOrSyncResponse(data); setState(self.DONE); } @@ -431,7 +411,7 @@ function XMLHttpRequest(opts) { try { this.status = 200; const syncData = fs.readFileSync(unescape(url.pathname)); - // Use self.responseType to create the correct self.responseType, self.response, self.XMLDocument. + // Use self.responseType to create the correct self.responseType, self.response. this.createFileOrSyncResponse(syncData); setState(self.DONE); } catch(e) { @@ -557,13 +537,10 @@ function XMLHttpRequest(opts) { // When responseType is 'text' or '', self.responseText will be utf8 decoded text. // When responseType is 'json', self.responseText initially will be utf8 decoded text, // which is then JSON parsed into self.response. - // When responseType is 'document', self.responseText initially will be utf8 decoded text, - // which is then parsed by npmjs package '@xmldom/xmldom'into a DOM object self.responseXML. // When responseType is 'arraybuffer', self.response is an ArrayBuffer. // When responseType is 'blob', self.response is a Blob. // cf. section 3.6, subsections 8,9,10,11 of https://xhr.spec.whatwg.org/#the-response-attribute - const isUtf8 = self.responseType === "" || self.responseType === "text" - || self.responseType === "json" || self.responseType === "document"; + const isUtf8 = self.responseType === "" || self.responseType === "text" || self.responseType === "json"; if (isUtf8 && response.setEncoding) { response.setEncoding("utf8"); } @@ -574,7 +551,7 @@ function XMLHttpRequest(opts) { // Make sure there's some data if (chunk) { if (isUtf8) { - // When responseType is 'text', '', 'json' or 'document', + // When responseType is 'text', '', 'json', // then each chunk is already utf8 decoded. self.responseText += chunk; } else { @@ -786,7 +763,7 @@ function XMLHttpRequest(opts) { /** * Construct the correct form of response, given responseType when in non-file based, asynchronous mode. * - * When self.responseType is "", "text", "json", "document", self.responseText is a utf8 string. + * When self.responseType is "", "text", "json", self.responseText is a utf8 string. * When self.responseType is "arraybuffer", "blob", the response is in the buffers parameter, * an Array of Buffers. Then concat(buffers) is Uint8Array, from which checkAndShrinkBuffer * extracts the correct sized ArrayBuffer. @@ -798,18 +775,12 @@ function XMLHttpRequest(opts) { switch (self.responseType) { case "": case "text": - self.response = Buffer.alloc(0); + self.response = self.responseText; break; case 'json': self.response = JSON.parse(self.responseText); self.responseText = ''; break; - case "document": - const xml = JSON.parse(self.responseText); - self.responseXML = html2dom(xml); - self.responseText = ''; - self.response = Buffer.alloc(0); - break; default: self.responseText = ''; const totalResponse = concat(buffers); @@ -828,7 +799,7 @@ function XMLHttpRequest(opts) { * Construct the correct form of response, given responseType when in synchronous mode or file based. * * The input is the response parameter which is a Buffer. - * When self.responseType is "", "text", "json", "document", + * When self.responseType is "", "text", "json", * the input is further refined to be: response.toString('utf8'). * When self.responseType is "arraybuffer", "blob", * the input is further refined to be: checkAndShrinkBuffer(response). @@ -842,16 +813,11 @@ function XMLHttpRequest(opts) { case "": case "text": self.responseText = response.toString('utf8'); - self.response = Buffer.alloc(0); + self.response = self.responseText; break; case 'json': self.response = JSON.parse(response.toString('utf8')); break; - case "document": - const xml = JSON.parse(response.toString('utf8')); - self.responseXML = html2dom(xml); - self.response = Buffer.alloc(0); - break; default: // When self.responseType === 'arraybuffer', self.response is an ArrayBuffer. // Get the correct sized ArrayBuffer. diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index ef1589d..82b0a67 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -121,5 +121,21 @@ function runTest() { assert(isSync, "XMLHttpRequest was not synchronous"); + xhr = new XMLHttpRequest(); + isSync = false; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4) { + assert.equal(xhr.response.toString(), 'Hello world!'); + assert.equal(xhr.getResponseHeader('content-type'), 'application/octet-stream') + isSync = true; + } + } + + xhr.open("GET", "http://localhost:8888/binary1", false); + xhr.send(); + + assert(isSync, "XMLHttpRequest was not synchronous"); + console.log("done"); } From 03b3f95e2b13fde49751b039c9a46dd0ce8752ae Mon Sep 17 00:00:00 2001 From: YarnSaw Date: Wed, 30 Oct 2024 20:39:03 -0400 Subject: [PATCH 5/6] Update tests to node 4 compatible --- tests/server.js | 32 ++- tests/test-perf.js | 66 +++--- tests/test-request-protocols-binary-data.js | 37 ++-- tests/test-response-type.js | 223 ++++++++++---------- tests/test-utf8-tearing.js | 91 ++++---- 5 files changed, 235 insertions(+), 214 deletions(-) diff --git a/tests/server.js b/tests/server.js index 3787f77..ac8bd55 100644 --- a/tests/server.js +++ b/tests/server.js @@ -1,36 +1,30 @@ - +'use strict'; var http = require("http"); var server = http.createServer(function (req, res) { switch (req.url) { case "/text": - return res - .writeHead(200, {"Content-Type": "text/plain"}) - .end("Hello world!"); + res.writeHead(200, {"Content-Type": "text/plain"}) + res.end("Hello world!"); case "/xml": - return res - .writeHead(200, {"Content-Type": "application/xml"}) - .end("Foobar"); + res.writeHead(200, {"Content-Type": "application/xml"}) + res.end("Foobar"); case "/json": - return res - .writeHead(200, {"Content-Type": "application/json"}) - .end(JSON.stringify({ foo: "bar" })); + res.writeHead(200, {"Content-Type": "application/json"}) + res.end(JSON.stringify({ foo: "bar" })); case "/binary1": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(Buffer.from("Hello world!")); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(Buffer.from("Hello world!")); case "/binary2": { const ta = new Float32Array([1, 5, 6, 7]); const buf = Buffer.from(ta.buffer); const str = buf.toString('binary'); - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(str); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(str); } default: - return res - .writeHead(404, {"Content-Type": "text/plain"}) - .end("Not found"); + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); } }).listen(8888); diff --git a/tests/test-perf.js b/tests/test-perf.js index ce128ec..2bf9634 100644 --- a/tests/test-perf.js +++ b/tests/test-perf.js @@ -7,14 +7,12 @@ const http = require("http"); -const useLocalXHR = true; -const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; -const { XMLHttpRequest } = require(XHRModule); +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; const supressConsoleOutput = false; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } var serverProcess; @@ -92,19 +90,16 @@ function createServer() { const u8 = concat(chunks); storage[req.url] = u8; // console.log('server end-handler', req.url, u8.length, req.headers); - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(`success:len ${u8.length}`); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); }); } else { if (!storage[req.url]) - return res - .writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) - .end("Not in storage"); + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(storage[req.url]); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); } }).listen(8888); process.on("SIGINT", function () { @@ -140,8 +135,9 @@ function upload(xhr, url, data) { }); } -function download (xhr, url, responseType = 'arraybuffer') +function download (xhr, url, responseType) { + responseType = responseType || 'arraybuffer'; return new Promise((resolve, reject) => { xhr.open("GET", url, true); @@ -189,35 +185,40 @@ const F32 = Buffer.from(_f32.buffer); const urlF32 = "http://localhost:8888/F32"; const xhr = new XMLHttpRequest(); +var handle, success, _t0; /** * 1) Upload Float32Array of length N=100,000,000. * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. */ -async function runTest() { - try { - let r = await upload(xhr, urlF32, F32); // big +function runTest() { + let r = upload(xhr, urlF32, F32); // big + return r.then(afterUpload) +} + +function afterUpload(r) { log('upload urlF32, F32 ', r); log('-----------------------------------------------------------------------------------'); checkStorage(); // Check what's in the mini-webserver storage. log('-----------------------------------------------------------------------------------'); - const _t0 = Date.now(); - let success = true; - const handle = setTimeout(() => { + _t0 = Date.now(); + success = true; + handle = setTimeout(() => { console.error('Download has taken longer than 5 seconds and hence it has failed!'); success = false; }, 5 * 1000) - const ab = await download(xhr, urlF32, 'arraybuffer'); // big + const ab = download(xhr, urlF32, 'arraybuffer'); // big + return ab.then(afterDownload); +} + +function afterDownload(ab) { clearTimeout(handle); console.log(`Download elapsed time:, ${Date.now() - _t0}ms`, ab.byteLength); console.info(['...waiting to see elapsed time of download...']) if (!success) throw new Error("Download has taken far too long!"); - } catch (e) { - console.log('BOOM', e); - } } /** @@ -226,11 +227,12 @@ async function runTest() { */ setTimeout(function () { runTest() - .then(() => { console.log("PASSED"); }) - .catch((e) => { console.log("FAILED"); throw e; }) - .finally(() => { - if (serverProcess) - serverProcess.close(); - serverProcess = null; - }); + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); }, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-request-protocols-binary-data.js b/tests/test-request-protocols-binary-data.js index bf6a08d..de14087 100644 --- a/tests/test-request-protocols-binary-data.js +++ b/tests/test-request-protocols-binary-data.js @@ -2,19 +2,21 @@ * Test GET file URL with both async and sync mode. * Use xhr.responseType = "arraybuffer". */ - +'use strict'; var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest const supressConsoleOutput = true; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } var url = "file://" + __dirname + "/testBinaryData"; -function download (url, isAsync = true) { - xhr = new XMLHttpRequest(); +function download (url, isAsync) { + if (isAsync === undefined) + isAsync = true; + var xhr = new XMLHttpRequest(); return new Promise((resolve, reject) => { xhr.open("GET", url, true); @@ -34,10 +36,13 @@ function download (url, isAsync = true) { }); } -async function runTest () { - try { +function runTest () { // Async - var ab = await download(url, /*isAsyn*/ true); + var ab = download(url, /*isAsyn*/ true); + return ab.then(afterAsyncDownload); +} + +function afterAsyncDownload(ab) { var str = Buffer.from(ab).toString('binary'); var strLog = logBinary(str); log('async phase', strLog); @@ -46,16 +51,17 @@ async function runTest () { log("done async phase"); // Sync - var abSync = await download(url, /*isAsyn*/ false); + var abSync = download(url, /*isAsyn*/ false); + return abSync.then(afterSyncDownload); +} + +function afterSyncDownload(abSync) { var str = Buffer.from(abSync).toString('binary'); var strLog = logBinary(str); log('sync phase', strLog); if ("0000 803f 0000 a040 0000 c040 0000 e040" !== strLog) throw new Error(`Failed test-request-protocols-binary-data sync phase: "0000 803f 0000 a040 0000 c040 0000 e040" !== ${strLog}`); log("done sync phase"); - } catch (e) { - console.error('FAILED', e); - } } runTest() @@ -64,7 +70,12 @@ runTest() function logBinary(data) { function log(data, idx) { - return data.charCodeAt(idx).toString(16).padStart(2, '0'); + const char = data.charCodeAt(idx).toString(16); + // node compatibility: padStart doesn't exist to make sure return is 2 characters + if (char.length === 1) + return '0' + char; + else + return char; } if (!data) return 'no data'; if (typeof data !== 'string') return 'not a string'; diff --git a/tests/test-response-type.js b/tests/test-response-type.js index 498c3b1..fe262da 100644 --- a/tests/test-response-type.js +++ b/tests/test-response-type.js @@ -16,15 +16,12 @@ 'use strict'; const http = require("http"); - -const useLocalXHR = true; -const XHRModule = useLocalXHR ? "../lib/XMLHttpRequest" : "xmlhttprequest-ssl"; -const { XMLHttpRequest } = require(XHRModule); +const XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; const supressConsoleOutput = true; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } var serverProcess; @@ -107,7 +104,7 @@ function concat (bufferArray) { const result = Buffer.alloc(length); for (k = 0; k < bufferArray.length; k++) { - result.set(bufferArray[k], offset); + bufferArray[k].copy(result, offset, 0, bufferArray[k].length) offset += bufferArray[k].length; } return result; @@ -124,11 +121,14 @@ const storage = { ralph: [1,2] }; function storageLength () { const result = {}; for (const key in storage) - result[key] = storage[key].length; + if (key !== '/Json') // json not stored when uploading, but is stored when retrieving, new key makes check fail + result[key] = storage[key].length; return result; } function checkStorage () { + log('-----------------------------------------------------------------------------------'); log('storage:', JSON.stringify(storageLength())); + log('-----------------------------------------------------------------------------------'); } // Xml doc for testing responseType "document" @@ -168,23 +168,24 @@ function createServer() { // console.log(u8.toString('utf8')); // console.log('-------------------'); // console.log(xmlDoc); - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(`success:len ${u8.length}`); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(`success:len ${u8.length}`); }); } else { if (!storage[req.url]) - return res - .writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) - .end("Not in storage"); - + { + res.writeHead(404, {"Content-Type": "text/plain; charset=utf8"}) + res.end("Not in storage"); + return; + } if (req.url === "/Utf8" || req.url === "/Utf8_2" || req.url === "/Json" || req.url === "/Xml") - return res - .writeHead(200, {"Content-Type": "text/plain; charset=utf8"}) - .end(storage[req.url].toString()); - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(storage[req.url]); + { + res.writeHead(200, {"Content-Type": "text/plain; charset=utf8"}) + res.end(storage[req.url].toString()); + return; + } + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(storage[req.url]); } }).listen(8888); process.on("SIGINT", function () { @@ -220,8 +221,9 @@ function upload(xhr, url, data) { }); } -function download (xhr, url, responseType = 'arraybuffer') +function download (xhr, url, responseType) { + responseType = responseType || 'arraybuffer'; return new Promise((resolve, reject) => { xhr.open("GET", url, true); @@ -279,10 +281,11 @@ const urlF32_2 = "http://localhost:8888/F32_2"; const urlUtf8 = "http://localhost:8888/Utf8"; const urlUtf8_2 = "http://localhost:8888/Utf8_2"; const urlJson = "http://localhost:8888/Json"; -const urlXml = "http://localhost:8888/Xml"; const xhr = new XMLHttpRequest(); +const type = (o) => { return `type=${o && o.constructor && o.constructor.name}`; }; + /** * 1) Upload Float32Array of length N=1,000,000. * Then download using xhr.responseType="arraybuffer" and check the the array lengths are the same. @@ -301,89 +304,84 @@ const xhr = new XMLHttpRequest(); * Check that the objects are the same by comparing the strings after calling JSON.stringify. * 6) Did a test of xhr.responseType="document" using a simple xml example. */ -async function runTest() { - const type = (o) => { return `type=${o?.constructor?.name}`; }; - try { - let r = await upload(xhr, urlF32, F32); // big - log('upload urlF32, F32 ', r); - r = await upload(xhr, urlUtf8, F32Utf8); // big - log('upload urlUtf8, F32Utf8 ', r); - - r = await upload(xhr, urlF32_2, F32_2); - log('upload urlF32_2, F32_2 ', r); - r = await upload(xhr, urlUtf8_2, F32Utf8_2); - log('upload urlUtf8_2, F32Utf8_2', r); - - r = await upload(xhr, urlXml, JSON.stringify(xmlDoc)); - log('upload:urlXml, xmlDoc ', r); - - const testJson = storageLength(); - r = await upload(xhr, urlJson, JSON.stringify(testJson)); - log('upload:urlJson, storage ', r); - - log('-----------------------------------------------------------------------------------'); - checkStorage(); // Check what's in the mini-webserver storage. - log('-----------------------------------------------------------------------------------'); - - const ab = await download(xhr, urlF32, 'arraybuffer'); // big - const f32 = new Float32Array(ab) - log('download urlF32 arraybuf', f32.byteLength, type(ab)); - if (f32.byteLength !== F32.length) - throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); - - const text1 = await download(xhr, urlUtf8, 'text'); // big - log('download urlUtf8 text', text1.length, type(text1)); - if (f32.byteLength !== text1.length) - throw new Error(`Download from urlUtf8 has incorrect length: ${f32.byteLength} !== ${text1.length}`); - - if (typeof Blob === 'function') { // Check to see if Blob exists. - const blob = await download(xhr, urlF32_2, 'blob'); - const blob_ab = await blob.arrayBuffer(); - const blob_f32 = new Float32Array(blob_ab); - log(`download urlF32_2 blob ${blob_f32.byteLength} ${type(blob)} `, blob_f32); - if (!isEqual(blob_f32, _f32_2)) - throw new Error(`Download from urlF32_2 has incorrect content: ${blob_f32} !== ${_f32_2}`); - } else { - // If Blob doesn't exist (node versions < 18), then use xhr.responseType="arraybuffer". - const ab_2 = await download(xhr, urlF32_2, 'arraybuffer'); - const ab_2_f32 = new Float32Array(ab_2); - log(`download urlF32_2 arraybuf ${ab_2_f32.byteLength} ${type(ab_2)}`, ab_2_f32); - if (!isEqual(ab_2_f32, _f32_2)) - throw new Error(`Download from urlF32_2 has incorrect content: ${ab_2_f32} !== ${_f32_2}`); - } - - const text2 = await download(xhr, urlUtf8_2, ''); - const text2_f32 = stringToFloat32Array(text2); - log('download urlUtf8_2 default', text2.length, type(text2), text2_f32); - if (!isEqual(text2_f32, _f32_2)) - throw new Error(`Download from urlUtf8_2 has incorrect content: ${text2_f32} !== ${_f32_2}`); - - try { - const { XMLSerializer } = require('@xmldom/xmldom'); // Throws when xmldom not installed. - const doc = await download(xhr, urlXml, 'document'); - const serialized = new XMLSerializer().serializeToString(doc); - const html = serialized.replace(/\t/g, ' '); - log(`download urlXml document ${html.length}`, type(doc), html); - if (html !== xmlDoc) - throw new Error(`Download from urlXml has incorrect content:\n ${html} !== ${xmlDoc}`); - } catch (e) { - if (e?.code === 'MODULE_NOT_FOUND') { - console.error('Please install npmjs package @xmldom/xmldom to test xhr.responseType==="document"'); - console.info('XMLHttpRequest.js also needs access to @xmldom/xmldom'); - } else { - throw e; // rethrow - } - } +function runTest() { + const uploadPromises = []; + var r; + return upload(xhr, urlF32, F32) // upload float32 + .then((r) => { + log('upload urlF32, F32 ', r); + }) + .then(() => { // download float32 + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { // make sure download is correct + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlUtf8, F32Utf8); + }) + .then((r) => { + log('upload urlUtf8, F32Utf8 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab); + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + return upload(xhr, urlF32_2, F32_2); + }) + .then((r) => { + log('upload urlF32_2, F32_2 ', r); + }) + .then(() => { + return download(xhr, urlF32, 'arraybuffer'); + }) + .then((ab) => { + const f32 = new Float32Array(ab) + log('download urlF32 arraybuf', f32.byteLength, type(ab)); + if (f32.byteLength !== F32.length) + throw new Error(`Download from urlF32 has incorrect length: ${f32.byteLength} !== ${F32.length}`); + }) + .then(() => { + log('XXXXXXXXXXXXXXXXX', urlUtf8_2, F32Utf8_2) + return upload(xhr, urlUtf8_2, F32Utf8_2); + }) + .then((r) => { + log('upload urlUtf8_2, F32Utf8_2', r); + }) + .then(() => { + return download(xhr, urlUtf8_2, 'text'); + }) + .then((text2) => { + const text2_f32 = stringToFloat32Array(text2); + log('download urlUtf8_2 default', text2.length, type(text2), text2_f32); + if (!isEqual(text2_f32, _f32_2)) + throw new Error(`Download from urlUtf8_2 has incorrect content: ${text2_f32} !== ${_f32_2}`); + }) + .then(() => { + return upload(xhr, urlJson, JSON.stringify(storageLength())); + }) + .then((r) => { + log('upload:urlJson, storage ', r); + }) + .then(() => { + return download(xhr, urlJson, 'json'); + }) + .then((json) => { + log(`download urlJson json ${JSON.stringify(json).length}`, type(json), json); + const testJson = storageLength(); + if (JSON.stringify(json) !== JSON.stringify(testJson)) + throw new Error(`Download from urlJson has incorrect content:\n ${JSON.stringify(json)} !== ${JSON.stringify(testJson)}`); + }); - const json = await download(xhr, urlJson, 'json'); - log(`download urlJson json ${JSON.stringify(json).length}`, type(json), json); - if (JSON.stringify(json) !== JSON.stringify(testJson)) - throw new Error(`Download from urlJson has incorrect content:\n ${JSON.stringify(json)} !== ${JSON.stringify(testJson)}`); - - } catch (e) { - log('BOOM', e); - throw e; // rethrow - } } /** @@ -392,11 +390,12 @@ async function runTest() { */ setTimeout(function () { runTest() - .then(() => { console.log("PASSED"); }) - .catch((e) => { console.log("FAILED"); throw e; }) - .finally(() => { - if (serverProcess) - serverProcess.close(); - serverProcess = null; - }); + .then(() => { console.log("PASSED"); shutdown(); }) + .catch((e) => { console.log("FAILED", e); shutdown(); throw e; }); }, 100); + +function shutdown() { + if (serverProcess) + serverProcess.close(); + serverProcess = null; +} diff --git a/tests/test-utf8-tearing.js b/tests/test-utf8-tearing.js index 3625bca..8c801be 100644 --- a/tests/test-utf8-tearing.js +++ b/tests/test-utf8-tearing.js @@ -18,12 +18,12 @@ var assert = require("assert"); var http = require("http"); -var { XMLHttpRequest } = require("../lib/XMLHttpRequest"); +var XMLHttpRequest = require("../lib/XMLHttpRequest").XMLHttpRequest; var supressConsoleOutput = true; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } var serverProcess; @@ -79,7 +79,9 @@ function createBuffers(f32) { var bufferUtf8 = Buffer.from(ss, 'utf8'); // Encode ss in utf8 return { buffer, bufferUtf8 }; } -var { buffer, bufferUtf8 } = createBuffers(f32); +var bufs = createBuffers(f32); +var buffer = bufs.buffer, + bufferUtf8 = bufs.bufferUtf8 /** * Serves up buffer at @@ -94,17 +96,17 @@ function createServer(buffer, bufferUtf8) { serverProcess = http.createServer(function (req, res) { switch (req.url) { case "/binary": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(buffer); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(buffer); + return; case "/binaryUtf8": - return res - .writeHead(200, {"Content-Type": "application/octet-stream"}) - .end(bufferUtf8); + res.writeHead(200, {"Content-Type": "application/octet-stream"}) + res.end(bufferUtf8); + return; default: - return res - .writeHead(404, {"Content-Type": "text/plain"}) - .end("Not found"); + res.writeHead(404, {"Content-Type": "text/plain"}) + res.end("Not found"); + return; } }).listen(8888); process.on("SIGINT", function () { @@ -146,7 +148,9 @@ function hexStr_to_ta(hexStr) { * @param {number} [count=1000] * @returns {boolean} */ -function checkEnough(ta1, ta2, count = 1000) { +function checkEnough(ta1, ta2, count) { + if (count === undefined) + count = 1000 assert(ta1 && ta2); if (ta1.constructor.name !== ta2.constructor.name) return false; if (ta1.length !== ta2.length) return false; @@ -164,8 +168,10 @@ var xhr = new XMLHttpRequest(); var url = "http://localhost:8888/binary"; var urlUtf8 = "http://localhost:8888/binaryUtf8"; -function download (xhr, url, responseType = 'arraybuffer') +function download (xhr, url, responseType) { + if (responseType === undefined) + responseType = 'arraybuffer'; return new Promise(function (resolve, reject) { xhr.open("GET", url, true); @@ -208,26 +214,28 @@ function download (xhr, url, responseType = 'arraybuffer') * @param {boolean} isUtf8 * @returns {Promise} */ -async function Get(url, isUtf8) { - var dataTxt = await download(xhr, url, 'text'); - var ab = await download(xhr, url, 'arraybuffer'); - var data = Buffer.from(ab); - - assert(dataTxt && data); - - log('XHR GET:', dataTxt.length, data.length, data.toString('utf8').length); - log('XHR GET:', data.constructor.name, dataTxt.constructor.name); - - if (isUtf8 && dataTxt.length !== data.toString('utf8').length) - throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); - - var ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); - log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); - - if (!checkEnough(ta, f32)) - throw new Error("Unable to correctly reconstitute Float32Array"); - - return ta; +function Get(url, isUtf8) { + return download(xhr, url, 'text').then((dataTxt) => { + return download(xhr, url, 'arraybuffer').then((ab) => { + var data = Buffer.from(ab); + + assert(dataTxt && data); + + log('XHR GET:', dataTxt.length, data.length, data.toString('utf8').length); + log('XHR GET:', data.constructor.name, dataTxt.constructor.name); + + if (isUtf8 && dataTxt.length !== data.toString('utf8').length) + throw new Error("xhr.responseText !== xhr.response.toString('utf8')"); + + var ta = isUtf8 ? new Float32Array(hexStr_to_ta(dataTxt)) : new Float32Array(data.buffer); + log('XHR GET:', ta.constructor.name, ta.length, ta[0], ta[1]); + + if (!checkEnough(ta, f32)) + throw new Error("Unable to correctly reconstitute Float32Array"); + + return ta; + }) + }); } /** @@ -252,11 +260,18 @@ function runTest() { */ setTimeout(function () { runTest() - .then(function (ta) { console.log("done", ta?.length); }) - .finally(function () { + .then(function (ta) { + console.log("done", ta && ta.length); + if (serverProcess) + serverProcess.close(); + serverProcess = null; + }) + .catch(function (e) { + console.log("FAILED"); if (serverProcess) serverProcess.close(); serverProcess = null; - }); + throw e; + }) }, 100); From 917d52b9928b1447fd1148adb262e4f224f2945e Mon Sep 17 00:00:00 2001 From: YarnSaw Date: Thu, 31 Oct 2024 21:24:49 -0400 Subject: [PATCH 6/6] Fix sync requests for old node versions, fix server switch returns --- lib/XMLHttpRequest.js | 10 ++++++---- tests/server.js | 8 ++++++-- tests/test-sync-response.js | 5 +++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/XMLHttpRequest.js b/lib/XMLHttpRequest.js index 729f45f..372d3c5 100644 --- a/lib/XMLHttpRequest.js +++ b/lib/XMLHttpRequest.js @@ -615,15 +615,17 @@ function XMLHttpRequest(opts) { var syncFile = ".node-xmlhttprequest-sync-" + process.pid; fs.writeFileSync(syncFile, "", "utf8"); // The async request the other Node process executes - var execString = "var http = require('http'), https = require('https'), fs = require('fs');" + var execString = "'use strict';" + + "var http = require('http'), https = require('https'), fs = require('fs');" + "function concat(bufferArray) {" + " let length = 0, offset = 0;" + " for (let k = 0; k < bufferArray.length; k++)" + " length += bufferArray[k].length;" + " const result = Buffer.alloc(length);" - + " for (let k = 0; k < bufferArray.length; k++)" - + " {" - + " result.set(bufferArray[k], offset);" + + " for (let k = 0; k < bufferArray.length; k++) {" + + " for (let i = 0; i < bufferArray[k].length; i++) {" + + " result[offset+i] = bufferArray[k][i]" + + " }" + " offset += bufferArray[k].length;" + " }" + " return result;" diff --git a/tests/server.js b/tests/server.js index ac8bd55..4649091 100644 --- a/tests/server.js +++ b/tests/server.js @@ -6,22 +6,26 @@ var server = http.createServer(function (req, res) { case "/text": res.writeHead(200, {"Content-Type": "text/plain"}) res.end("Hello world!"); + return; case "/xml": res.writeHead(200, {"Content-Type": "application/xml"}) res.end("Foobar"); + return; case "/json": res.writeHead(200, {"Content-Type": "application/json"}) res.end(JSON.stringify({ foo: "bar" })); + return; case "/binary1": res.writeHead(200, {"Content-Type": "application/octet-stream"}) res.end(Buffer.from("Hello world!")); - case "/binary2": { + return; + case "/binary2": const ta = new Float32Array([1, 5, 6, 7]); const buf = Buffer.from(ta.buffer); const str = buf.toString('binary'); res.writeHead(200, {"Content-Type": "application/octet-stream"}) res.end(str); - } + return; default: res.writeHead(404, {"Content-Type": "text/plain"}) res.end("Not found"); diff --git a/tests/test-sync-response.js b/tests/test-sync-response.js index 82b0a67..316027e 100644 --- a/tests/test-sync-response.js +++ b/tests/test-sync-response.js @@ -2,6 +2,7 @@ * Test GET http URL with both async and sync mode. * Use xhr.responseType = "" and "arraybuffer". */ +'use strict'; var assert = require("assert") , spawn = require('child_process').spawn @@ -9,9 +10,9 @@ var assert = require("assert") , serverProcess; const supressConsoleOutput = true; -function log (...args) { +function log (_) { if ( !supressConsoleOutput) - console.debug(...args); + console.log(arguments); } // Running a sync XHR and a webserver within the same process will cause a deadlock