mock-violentmonkey
Version:
Mock violentmonkey's globals for testing userscripts
197 lines • 6.69 kB
JavaScript
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
import { JSDOM } from 'jsdom';
import { getBaseUrl } from '../base-url.js';
import { getWindow } from '../dom.js';
import { XMLHttpRequest, } from '../xmlhttprequest/index.js';
const formDataToBuffer = async (data) => {
const boundary = '-'.repeat(20) + crypto.randomBytes(20).toString('hex');
const body = [];
for (const [key, value] of data) {
body.push(`--${boundary}`, `Content-Disposition: form-data; name=${JSON.stringify(key)}`);
if (typeof value === 'string') {
// Two CRLF
body.push('', value);
}
else {
await new Promise((resolve, reject) => {
const fr = new (getWindow().FileReader)();
fr.addEventListener('load', () => {
const result = fr.result;
// eslint-disable-next-line unicorn/prefer-at
body[body.length - 1] += `; filename=${JSON.stringify(value.name)}`;
// Two CRLF before result
body.push(`Content-Type: ${value.type || 'application/octet-stream'}`, '', result);
resolve();
});
fr.addEventListener('error', () => {
reject(fr.error);
});
// For some reason I can't figure out, Blob#text() just times out
// eslint-disable-next-line unicorn/prefer-blob-reading-methods
fr.readAsText(value);
});
}
}
// Trailing CRLF
body.push(`--${boundary}--`, '');
const contentType = `multipart/form-data; boundary=${boundary}`;
return {
content: Buffer.from(body.join('\r\n')),
contentType,
};
};
const dataToBuffer = async (data) => {
if (data === undefined) {
return {
content: undefined,
contentType: undefined,
};
}
if (typeof data === 'string') {
return {
content: Buffer.from(data),
contentType: 'text/plain;charset=UTF-8',
};
}
if (data instanceof getWindow().FormData ||
(typeof FormData !== 'undefined' && data instanceof FormData)) {
return formDataToBuffer(data);
}
if (data instanceof Blob) {
return {
content: Buffer.from(await data.arrayBuffer()),
contentType: data.type || undefined,
};
}
return dataToBuffer(String(data));
};
const responseToResponseType = (xhr, responseType) => {
responseType = String(responseType).toLowerCase();
const buffer = xhr.responseBuffer;
switch (String(responseType).toLowerCase()) {
case 'arraybuffer': {
const arrayBuffer = new ArrayBuffer(buffer.byteLength);
const view = new Uint8Array(arrayBuffer);
for (const [index, value] of buffer.entries()) {
view[index] = value;
}
return arrayBuffer;
}
case 'json': {
try {
return JSON.parse(buffer.toString());
}
catch (error) {
return error;
}
}
case 'blob': {
return new Blob([buffer], {
type: xhr.getResponseHeader('content-type'),
});
}
case 'document': {
return new JSDOM(buffer.toString(), {
url: xhr.responseURL,
}).window.document;
}
default: {
return buffer.toString();
}
}
};
const makeEventResponse = (xhr, details) => {
// Caching this one is necessary because otherwise
// `responseObject.response === responseObject.response` returns `false`
// if they are some form of object
let response;
// Caching this one is optional, since comparing two equal strings returns `true`
let responseText;
return Object.defineProperties({
status: xhr.status,
statusText: xhr.statusText,
readyState: xhr.readyState,
responseHeaders: xhr.getAllResponseHeaders(),
finalUrl: xhr.responseURL,
context: details.context,
}, {
response: {
get() {
response ??= responseToResponseType(xhr, details.responseType);
return response;
},
},
responseText: {
get() {
responseText ??= xhr.responseBuffer.toString();
return responseText;
},
},
});
};
const xmlhttpRequest = (details) => {
let aborted = false;
// eslint-disable-next-line promise/prefer-await-to-then
void dataToBuffer(details.data).then(({ content, contentType }) => {
const xhr = new XMLHttpRequest({
base: getBaseUrl(),
});
aborter.abort = () => {
xhr.abort();
};
if (typeof details.timeout === 'number') {
xhr.timeout = details.timeout;
}
const events = [
'abort',
'error',
'load',
'loadend',
'loadstart',
'progress',
'readystatechange',
'timeout',
];
for (const event of events) {
xhr.addEventListener(event, () => {
const callback = details[`on${event}`];
if (typeof callback === 'function') {
// eslint-disable-next-line promise/no-callback-in-promise
void callback(makeEventResponse(xhr, details));
}
});
}
xhr.open(details.method ?? 'get', details.url, details.user, details.password);
if (details.headers) {
for (const [key, value] of Object.entries(details.headers)) {
xhr.setRequestHeader(key, value);
}
}
if (contentType !== undefined && !xhr.getRequestHeader('content-type')) {
xhr.setRequestHeader('content-type', contentType);
}
xhr.send(content);
// Doing it like this makes sure that
// it behaves just like asynchronous abort
if (aborted) {
xhr.abort();
}
});
// Once xhr exists switch this out
// This is necessary because turning the data to a buffer
// is asynchronous but GM_xmlhttpRequest not
const aborter = {
abort() {
aborted = true;
},
};
return aborter;
};
export { xmlhttpRequest as GM_xmlhttpRequest, };
Object.defineProperties(globalThis, {
GM_xmlhttpRequest: {
value: xmlhttpRequest,
},
});
//# sourceMappingURL=xmlhttprequest.js.map