Skip to content

Commit

Permalink
ESM Test Mocking
Browse files Browse the repository at this point in the history
Trying to resolve the rest of the issues with the ESM rewrite of the Jest tests. Making the mocking procedures ESM-compliant was part of the holdup, at least in terms of that it uses different Jest functions that are specific to ESM, which were different when previously compiling back to CJS as the endpoint.

Just to make things a bit easier to debug and understand, I moved the Node FS and XMLHttpRequest mocks into the tests themselves, since it doesn't appear that you can have a custom mocking folder structure, unlike which you can with regular tests.

The mocking still isn't fully working, but these resources helped lead me to this point now.

https://stackoverflow.com/questions/40465047/how-can-i-mock-an-es6-module-import-using-jest
https://remarkablemark.org/blog/2018/06/28/jest-mock-default-named-export/
jestjs/jest#10025
https://github.com/connorjburton/js-ts-jest-esm-mock
  • Loading branch information
Offroaders123 committed Apr 17, 2023
1 parent fab1e1a commit 348fd60
Show file tree
Hide file tree
Showing 4 changed files with 288 additions and 299 deletions.
104 changes: 101 additions & 3 deletions test/NodeFileReader.test.ts
@@ -1,14 +1,39 @@
import { jest } from "@jest/globals";

import * as fs from "node:fs";
import NodeFileReader from "../src/NodeFileReader.js";

/**
* Extended from https://facebook.github.io/jest/docs/manual-mocks.html
*/
// Get the real (not mocked) version of the 'path' module
import * as path from "node:path";

// Get the automatic mock for `fs`
jest
.mock<typeof import("node:fs")>("node:fs")
.unstable_mockModule("node:fs",() => ({
// Override the default behavior of the `readdirSync` mock
readdirSync: jest.fn(readdirSync),
open: jest.fn(open),
read: jest.fn(read),
stat: jest.fn(stat),
// Add a custom method to the mock
__setMockFiles: jest.fn(__setMockFiles)
}))
.dontMock("../src/NodeFileReader.js")
.dontMock("../src/MediaFileReader.js")
.dontMock("../src/ChunkedFileData.js");

const fs = await import("node:fs");

interface MockFiles {
[file: string]: string | MockFiles;
}

// This is a custom function that our tests can use during setup to specify
// what the files on the "mock" filesystem should look like when any of the
// `fs` APIs are used.
let _mockFiles: MockFiles = {};

describe("NodeFileReader", () => {
let fileReader: NodeFileReader;

Expand Down Expand Up @@ -83,4 +108,77 @@ describe("NodeFileReader", () => {
});
expect(true).toBe(true);
});
});
});

function __setMockFiles(newMockFiles: MockFiles) {
_mockFiles = {};

for (const file in newMockFiles) {
const dir = path.dirname(file);

if (!_mockFiles[dir]) {
_mockFiles[dir] = {};
}

// @ts-expect-error
_mockFiles[dir][path.basename(file)] = newMockFiles[file];
}
};

// A custom version of `readdirSync` that reads from the special mocked out
// file list set via __setMockFiles
function readdirSync(directoryPath: string) {
return _mockFiles[directoryPath] || [];
};

const _fds: { path: string; }[] = [];
function open(path: string, flags: unknown, mode: unknown, callback: (error: Error | null, fd: number) => void) {
const fd = _fds.push({
path: path
}) - 1;

process.nextTick(() => {
if (callback) {
callback(null, fd);
}
});
}

function read(fd: number, buffer: Buffer, offset: number, length: number, position: number, callback: (error: Error | null, length?: number, buffer?: Buffer) => void) {
const file = _fds[fd];
const dir = path.dirname(file.path);
const name = path.basename(file.path);

// @ts-expect-error
if (_mockFiles[dir] && _mockFiles[dir][name]) {
// @ts-expect-error
const data = _mockFiles[dir][name].substr(position, length);
buffer.write(data, offset, length);
process.nextTick(() => {
callback(null, length, buffer);
});
} else {
process.nextTick(() => {
callback(new Error("File not found"));
});
}
}

function stat(_path: string, callback: (error: Error | null, stat?: { size: number }) => void) {
const dir = path.dirname(_path);
const name = path.basename(_path);

// @ts-expect-error
if (_mockFiles[dir] && _mockFiles[dir][name]) {
process.nextTick(() => {
callback(null, {
// @ts-expect-error
size: _mockFiles[dir][name].length
});
});
} else {
process.nextTick(() => {
callback({} as Error);
})
}
}
191 changes: 187 additions & 4 deletions test/XhrFileReader.test.ts
@@ -1,15 +1,41 @@
import { jest } from "@jest/globals";

import XhrFileReader from "../src/XhrFileReader.js";
// @ts-expect-error
import * as xhr2 from "xhr2";

jest
.mock("xhr2")
.unstable_mockModule("xhr2",() => ({
__setMockUrls: jest.fn(__setMockUrls),
default: jest.fn(XMLHttpRequest)
}))
.dontMock("../src/XhrFileReader.js")
.dontMock("../src/MediaFileReader.js")
.dontMock("../src/ChunkedFileData.js");

// @ts-expect-error
const { default: xhr2 } = await import("xhr2");

interface MockURLs {
[url: string]: MockURL;
}

type MockURL = string | MockURLData;

interface MockURLData {
contents: string;
disableRange: boolean;
unknownLength: boolean;
disallowedHeaders: string[];
statusCode: number;
timeout: number;
}

let _mockUrls: MockURLs = {};

// @ts-ignore
const XMLHttpRequest = new XMLHttpRequestMock();
// @ts-expect-error
globalThis.XMLHttpRequest = () => XMLHttpRequest;

function throwOnError(onSuccess: (error?: any) => void) {
return {
onSuccess,
Expand Down Expand Up @@ -234,4 +260,161 @@ describe("XhrFileReader", () => {
});
expect(true).toBe(true);
});
});
});

function __setMockUrls(newMockUrls: MockURLs) {
_mockUrls = {};

for (const url in newMockUrls) {
_mockUrls[url] = newMockUrls[url];
}
};

function isRangeDisabled(url: string) {
return !!((_mockUrls[url] || {}) as MockURLData).disableRange;
}

function getUrlContents(url: string, range?: [number,number] | null): string | null {
const urlData = _mockUrls[url];

if (urlData == null) {
return null;
}

if ((urlData as MockURLData).disableRange) {
range = null;
}

let contents: string;
if (typeof urlData === "string") {
contents = urlData;
} else {
contents = urlData.contents;
}

return range ? contents.slice(range[0], range[1] + 1) : contents;
}

function getUrlFileLength(url: string) {
const urlData = _mockUrls[url];

if (urlData == null || (urlData as MockURLData).unknownLength) {
return null;
}

return getUrlContents(url)!.length;
}

function isHeaderDisallowed(url: string, header?: string) {
const urlData = _mockUrls[url];
return (
urlData != null &&
((urlData as MockURLData).disallowedHeaders || []).indexOf(header!) >= 0
);
}

function getUrlContentLength(url: string, range: [number,number] | null) {
if (isHeaderDisallowed(url, "content-length")) {
return null;
}

return getUrlContents(url, range)!.length;
}

function getUrlStatusCode(url: string) {
const urlData = _mockUrls[url];

if (urlData == null) {
return 404;
} else {
return (urlData as MockURLData).statusCode || 200;
}
}

function getTimeout(url: string) {
const urlData = _mockUrls[url];
return urlData ? (urlData as MockURLData).timeout : 0;
}

interface XMLHttpRequestMock {
onload(): void;
open(method: string, url: string): void;
overrideMimeType(): void;
setRequestHeader(headerName: string, headerValue: string): void;
getResponseHeader(headerName: string): string | number | void | null;
_getContentRange(): string | void;
getAllResponseHeaders(): void;
send(): void;
status: number | null;
responseText: string | null;
timeout?: number;
ontimeout?: (error: Error) => void;
}

function XMLHttpRequestMock(this: XMLHttpRequestMock) {
let _url: string;
let _range: [number,number] | null;

this.onload = () => {};
this.open = jest.fn<typeof this.open>().mockImplementation((method, url) => {
_url = url;
_range = null;
});
this.overrideMimeType = jest.fn<typeof this.overrideMimeType>();
this.setRequestHeader = jest.fn<typeof this.setRequestHeader>().mockImplementation(
(headerName, headerValue) => {
if (headerName.toLowerCase() === "range") {
const matches = headerValue.match(/bytes=(\d+)-(\d+)/)!;
_range = [Number(matches[1]), Number(matches[2])];
}
}
);
this.getResponseHeader = jest.fn<typeof this.getResponseHeader>().mockImplementation(
headerName => {
if (headerName.toLowerCase() === "content-length") {
return getUrlContentLength(_url, _range);
} else if (headerName.toLowerCase() === "content-range") {
return this._getContentRange();
}
}
);
this._getContentRange = () => {
if (_range && !isRangeDisabled(_url) && !isHeaderDisallowed("content-range")) {
const endByte = Math.min(_range[1], getUrlContents(_url)!.length - 1);
return "bytes " + _range[0] + "-" + endByte + "/" + (getUrlFileLength(_url) || "*");
}
}
this.getAllResponseHeaders = jest.fn().mockImplementation(
() => {
const headers = [];

headers.push("content-length: " + getUrlContentLength(_url, _range));
if (this._getContentRange()) {
headers.push("content-range: " + this._getContentRange());
}

return headers.join("\r\n");
}
);
this.send = jest.fn().mockImplementation(() => {
const requestTimeout = getTimeout(_url);

setTimeout(
() => {
this.status = getUrlStatusCode(_url);
this.responseText = getUrlContents(_url, _range);
this.onload();
},
requestTimeout
);

if (requestTimeout && this.timeout && requestTimeout > this.timeout && this.ontimeout) {
setTimeout(
() => {
this.ontimeout?.({} as Error);
},
this.timeout
);
}
});
}

0 comments on commit 348fd60

Please sign in to comment.