diff --git a/.changeset/healthy-ladybugs-travel.md b/.changeset/healthy-ladybugs-travel.md
new file mode 100644
index 00000000..5ac2f021
--- /dev/null
+++ b/.changeset/healthy-ladybugs-travel.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-stuff-render-server": patch
+---
+
+Modified ICloseable to use void
diff --git a/.changeset/serious-frogs-lay.md b/.changeset/serious-frogs-lay.md
new file mode 100644
index 00000000..5fbc8661
--- /dev/null
+++ b/.changeset/serious-frogs-lay.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/wonder-stuff-render-environment-jsdom": major
+---
+
+Migrated JSDOM render environment from Khan/render-gateway
diff --git a/build-settings/rollup.config.js b/build-settings/rollup.config.js
index bf594d69..f79c0a8a 100644
--- a/build-settings/rollup.config.js
+++ b/build-settings/rollup.config.js
@@ -46,11 +46,21 @@ const makePackageBasedPath = (pkgName, pkgRelPath) =>
/**
* Generate the rollup output configuration for a given
*/
-const createOutputConfig = (pkgName, format, targetFile) => ({
- file: makePackageBasedPath(pkgName, targetFile),
- sourcemap: true,
- format,
-});
+const createOutputConfig = (pkgName, format, targetFile, isSingleFile) => {
+ const outputConfig = {
+ sourcemap: true,
+ format,
+ };
+ if (isSingleFile) {
+ outputConfig.file = makePackageBasedPath(pkgName, targetFile);
+ } else {
+ outputConfig.dir = makePackageBasedPath(
+ pkgName,
+ path.dirname(targetFile),
+ );
+ }
+ return outputConfig;
+};
/**
* Get a set of strings from a given string, returning the defaults
@@ -103,8 +113,9 @@ const createConfig = (
const extensions = [".js", ".jsx", ".ts", ".tsx"];
+ const isSingleFile = inputFile != null;
const config = {
- output: createOutputConfig(name, format, file),
+ output: createOutputConfig(name, format, file, isSingleFile),
input: makePackageBasedPath(name, inputFile || "./src/index.ts"),
plugins: [
// We don't want to do process.env.NODE_ENV checks in our main
@@ -181,6 +192,7 @@ const getPackageInfo = (commandLineArgs, pkgName) => {
platform: "browser",
file: cjsBrowser,
plugins: [],
+ inputFile: `./src/index.ts`,
});
}
if (formats.has("esm") && esmBrowser) {
@@ -191,6 +203,7 @@ const getPackageInfo = (commandLineArgs, pkgName) => {
file: esmBrowser,
// We care about the file size of this one.
plugins: [filesize()],
+ inputFile: `./src/index.ts`,
});
}
}
diff --git a/package.json b/package.json
index fc3ef8da..ab0ab0c0 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@types/express-winston": "^4.0.0",
"@types/jest": "^29.5.0",
"@types/jest-when": "^3.5.2",
+ "@types/jsdom": "^21.1.1",
"@rollup/plugin-terser": "^0.4.0",
"@types/superagent": "^4.1.16",
"@types/winston": "^2.4.4",
@@ -60,6 +61,7 @@
"jest": "^29.5.0",
"jest-extended": "^3.2.4",
"jest-when": "^3.5.2",
+ "jsdom": "^21.1.1",
"npm-package-json-lint": "^6.4.0",
"prettier": "^2.8.7",
"rollup": "^2.79.1",
diff --git a/packages/wonder-stuff-render-environment-jsdom/package.json b/packages/wonder-stuff-render-environment-jsdom/package.json
index c1053e61..37c1f151 100644
--- a/packages/wonder-stuff-render-environment-jsdom/package.json
+++ b/packages/wonder-stuff-render-environment-jsdom/package.json
@@ -14,10 +14,17 @@
"scripts": {
"test": "bash -c 'yarn --silent --cwd \"../..\" test ${@:0} $($([[ ${@: -1} = -* ]] || [[ ${@: -1} = bash ]]) && echo $PWD)'"
},
- "dependencies": {},
+ "dependencies": {
+ "@khanacademy/wonder-stuff-core": "^1.3.0",
+ "@khanacademy/wonder-stuff-server": "^4.0.2",
+ "@khanacademy/wonder-stuff-render-server": "^0.0.1"
+ },
"devDependencies": {
"ws-dev-build-settings": "^1.0.0"
},
+ "peerDependencies": {
+ "jsdom": "^21.1.1"
+ },
"author": "",
"license": "MIT"
}
\ No newline at end of file
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__data__/file-loader-test.txt b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__data__/file-loader-test.txt
new file mode 100644
index 00000000..97bdcaaa
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__data__/file-loader-test.txt
@@ -0,0 +1 @@
+THIS IS TEST CONTENT FOR THE SNAPSHOT!
\ No newline at end of file
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-configuration.test.ts.snap b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-configuration.test.ts.snap
new file mode 100644
index 00000000..ac91b0fe
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-configuration.test.ts.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`JSDOMConfiguration #constructor should throw if invalid getFileList is provided 1`] = `"Must provide valid callback for obtaining file list"`;
+
+exports[`JSDOMConfiguration #constructor should throw if invalid getFileList is provided 2`] = `"Must provide valid callback for obtaining file list"`;
+
+exports[`JSDOMConfiguration #constructor should throw if invalid getResourceLoader is provided 1`] = `"Must provide valid callback for obtaining resource loader"`;
+
+exports[`JSDOMConfiguration #constructor should throw if invalid getResourceLoader is provided 2`] = `"Must provide valid callback for obtaining resource loader"`;
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-environment.test.ts.snap b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-environment.test.ts.snap
new file mode 100644
index 00000000..1f9e9fa3
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-environment.test.ts.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`JSDOMEnvironment #render should reject if result is malformed ({ body: 'NEED MORE THAN THIS' }) 1`] = `"Malformed render result: {"body":"NEED MORE THAN THIS"}"`;
+
+exports[`JSDOMEnvironment #render should reject if result is malformed ({ body: 'THIS HELPS BUT WHERE ARE THE HEADERS', status: 200 }) 1`] = `"Malformed render result: {"body":"THIS HELPS BUT WHERE ARE THE HEADERS","status":200}"`;
+
+exports[`JSDOMEnvironment #render should reject if result is malformed ({ status: 200, headers: {} }) 1`] = `"Malformed render result: {"status":200,"headers":{}}"`;
+
+exports[`JSDOMEnvironment #render should reject if result is malformed (THIS IS NOT CORRECT) 1`] = `"Malformed render result: "THIS IS NOT CORRECT""`;
+
+exports[`JSDOMEnvironment #render should reject if result is malformed (null) 1`] = `"Malformed render result: null"`;
+
+exports[`JSDOMEnvironment #render should reject if result is malformed (undefined) 1`] = `"Malformed render result: undefined"`;
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/apply-abortable-promises-patch.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/apply-abortable-promises-patch.test.ts
new file mode 100644
index 00000000..a07cb19c
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/apply-abortable-promises-patch.test.ts
@@ -0,0 +1,61 @@
+import {applyAbortablePromisesPatch} from "../apply-abortable-promises-patch";
+
+describe("#applyAbortablePromisesPatch", () => {
+ afterEach(() => {
+ // @ts-expect-error We know promise doesn't usually have this
+ delete Promise.prototype.abort;
+ });
+
+ it("should add an abort method to the promise prototype", () => {
+ // Arrange
+
+ // Act
+ applyAbortablePromisesPatch();
+ const result: any = Promise.resolve();
+
+ // Assert
+ expect(result.abort).toBeFunction();
+ });
+
+ it("should replace any existing abort method on the promise prototype", () => {
+ // Arrange
+ // @ts-expect-error We know promise doesn't usually have this
+ Promise.prototype.abort = "ABORT_FN";
+
+ // Act
+ applyAbortablePromisesPatch();
+ const result: any = Promise.resolve();
+
+ // Assert
+ expect(result.abort).toBeFunction();
+ });
+
+ it("should not delete the existing function if it was applied by us", () => {
+ // Arrange
+ applyAbortablePromisesPatch();
+ // @ts-expect-error We know promise doesn't usually have this
+ const abortFn = Promise.prototype.abort;
+
+ // Act
+ applyAbortablePromisesPatch();
+ const result: any = Promise.resolve();
+
+ // Assert
+ expect(result.abort).toBe(abortFn);
+ });
+
+ it("should delete the existing function if it was applied by us and force is true", () => {
+ // Arrange
+ applyAbortablePromisesPatch();
+ // @ts-expect-error We know promise doesn't usually have this
+ const abortFn = Promise.prototype.abort;
+
+ // Act
+ applyAbortablePromisesPatch(true);
+ const result: any = Promise.resolve();
+
+ // Assert
+ expect(result.abort).not.toBe(abortFn);
+ expect(result.abort).toBeFunction();
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/closeable-virtual-console.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/closeable-virtual-console.test.ts
new file mode 100644
index 00000000..d8db188c
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/closeable-virtual-console.test.ts
@@ -0,0 +1,124 @@
+import * as WSRenderServer from "@khanacademy/wonder-stuff-render-server";
+import {CloseableVirtualConsole} from "../closeable-virtual-console";
+
+jest.mock("@khanacademy/wonder-stuff-render-server");
+
+describe("CloseableVirtualConsole", () => {
+ describe("before close() is called", () => {
+ it("should ignore jsdomError events for 'Could not load img'", () => {
+ // Arrange
+ const fakeLogger: any = {
+ error: jest.fn(),
+ };
+ const underTest = new CloseableVirtualConsole(fakeLogger);
+
+ // Act
+ underTest.emit("jsdomError", new Error("Could not load img"));
+
+ // Assert
+ expect(fakeLogger.error).not.toHaveBeenCalled();
+ });
+
+ it("should report jsdomError events as logger.error", () => {
+ // Arrange
+ const fakeLogger: any = {
+ error: jest.fn(),
+ };
+ jest.spyOn(WSRenderServer, "extractError").mockReturnValue({
+ error: "ERROR_MESSAGE",
+ stack: "ERROR_STACK",
+ });
+ const underTest = new CloseableVirtualConsole(fakeLogger);
+
+ // Act
+ underTest.emit(
+ "jsdomError",
+ new Error("This is a jsdomError message"),
+ );
+
+ // Assert
+ expect(fakeLogger.error).toHaveBeenCalledWith(
+ "JSDOM jsdomError:ERROR_MESSAGE",
+ {
+ error: "ERROR_MESSAGE",
+ kind: "Internal",
+ stack: "ERROR_STACK",
+ },
+ );
+ });
+
+ it("should pass errors to logger.error with args as metadata", () => {
+ // Arrange
+ const fakeLogger: any = {
+ error: jest.fn(),
+ };
+ const underTest = new CloseableVirtualConsole(fakeLogger);
+
+ // Act
+ underTest.emit(
+ "error",
+ "This is an error message",
+ "and these are args",
+ );
+
+ // Assert
+ expect(fakeLogger.error).toHaveBeenCalledWith(
+ "JSDOM error:This is an error message",
+ {
+ args: ["and these are args"],
+ },
+ );
+ });
+
+ it.each(["warn", "info", "log", "debug"])(
+ "should pass %s through to logger silly with args as metadata",
+ (method: string) => {
+ // Arrange
+ const fakeLogger: any = {
+ silly: jest.fn(),
+ };
+ const underTest = new CloseableVirtualConsole(fakeLogger);
+
+ // Act
+ underTest.emit(
+ method,
+ "This is a logged message",
+ "and these are args",
+ );
+
+ // Assert
+ expect(fakeLogger.silly).toHaveBeenCalledWith(
+ `JSDOM ${method}:This is a logged message`,
+ {
+ args: ["and these are args"],
+ },
+ );
+ },
+ );
+ });
+
+ describe("after close() is called", () => {
+ it.each(["jsdomError", "error", "warn", "log", "debug", "info"])(
+ "it should not log anything for %s",
+ (method: any) => {
+ // Arrange
+ const fakeLogger: any = {
+ /*nothing*/
+ };
+ const console = new CloseableVirtualConsole(fakeLogger);
+
+ // Act
+ console.close();
+ const underTest = () =>
+ console.emit(
+ method,
+ "This is a logged message",
+ "and these are args",
+ );
+
+ // Assert
+ expect(underTest).not.toThrow();
+ },
+ );
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/index.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/index.test.ts
new file mode 100644
index 00000000..be8c12df
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/index.test.ts
@@ -0,0 +1,17 @@
+describe("index.js", () => {
+ it("should export what we expect it to export", async () => {
+ // Arrange
+ const importedModule = import("../index");
+
+ // Act
+ const result = await importedModule;
+
+ // Assert
+ expect(result).toContainAllKeys([
+ "Configuration",
+ "Environment",
+ "ResourceLoader",
+ "FileResourceLoader",
+ ]);
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-configuration.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-configuration.test.ts
new file mode 100644
index 00000000..88bfd8d7
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-configuration.test.ts
@@ -0,0 +1,143 @@
+import {JSDOMConfiguration} from "../jsdom-configuration";
+
+describe("JSDOMConfiguration", () => {
+ describe("#constructor", () => {
+ it.each([null, "not a function"])(
+ "should throw if invalid getFileList is provided",
+ (badGetFileList: any) => {
+ // Arrange
+
+ // Act
+ const underTest = () =>
+ new JSDOMConfiguration(badGetFileList, jest.fn());
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingSnapshot();
+ },
+ );
+
+ it.each([null, "not a function"])(
+ "should throw if invalid getResourceLoader is provided",
+ (badGetResourceLoader: any) => {
+ // Arrange
+
+ // Act
+ const underTest = () =>
+ new JSDOMConfiguration(jest.fn(), badGetResourceLoader);
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingSnapshot();
+ },
+ );
+
+ it("should throw if invalid afterEnvSetup is provided", () => {
+ // Arrange
+
+ // Act
+ const underTest = () =>
+ new JSDOMConfiguration(
+ jest.fn(),
+ jest.fn(),
+ "not a function" as any,
+ );
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
+ `"Must provide valid callback for after env setup or null"`,
+ );
+ });
+ });
+
+ describe("#getFileList", () => {
+ it("should invoke method passed at construction", async () => {
+ // Arrange
+ const fakeGetFileList = jest
+ .fn()
+ .mockReturnValue(Promise.resolve("FILE_LIST" as any));
+ const underTest = new JSDOMConfiguration(
+ fakeGetFileList,
+ jest.fn(),
+ );
+ const fakeFetchFn = jest.fn();
+ const fakeRenderAPI: any = "FAKE_RENDER_API";
+
+ // Act
+ const result = await underTest.getFileList(
+ "URL",
+ fakeRenderAPI,
+ fakeFetchFn,
+ );
+
+ // Assert
+ expect(fakeGetFileList).toHaveBeenCalledWith(
+ "URL",
+ fakeRenderAPI,
+ fakeFetchFn,
+ );
+ expect(result).toBe("FILE_LIST");
+ });
+ });
+
+ describe("#getResourceLoader", () => {
+ it("should invoke method passed at construction", () => {
+ // Arrange
+ const fakeGetResourceLoader = jest
+ .fn()
+ .mockReturnValue("RESOURCE_LOADER" as any);
+ const underTest = new JSDOMConfiguration(
+ jest.fn(),
+ fakeGetResourceLoader,
+ );
+ const fakeRenderAPI: any = "FAKE_RENDER_API";
+
+ // Act
+ const result = underTest.getResourceLoader("URL", fakeRenderAPI);
+
+ // Assert
+ expect(fakeGetResourceLoader).toHaveBeenCalledWith(
+ "URL",
+ fakeRenderAPI,
+ );
+ expect(result).toBe("RESOURCE_LOADER");
+ });
+ });
+
+ describe("#afterEnvSetup", () => {
+ it("should invoke method passed at construction", async () => {
+ // Arrange
+ const fakeAfterEnvSetup = jest.fn().mockResolvedValue(null);
+ const underTest = new JSDOMConfiguration(
+ jest.fn(),
+ jest.fn(),
+ fakeAfterEnvSetup,
+ );
+ const fakeRenderAPI: any = "FAKE_RENDER_API";
+
+ // Act
+ await underTest.afterEnvSetup("URL", ["A", "B"], fakeRenderAPI);
+
+ // Assert
+ expect(fakeAfterEnvSetup).toHaveBeenCalledWith(
+ "URL",
+ ["A", "B"],
+ fakeRenderAPI,
+ );
+ });
+
+ it("should resolve to null if no method passed at construction", async () => {
+ // Arrange
+ const underTest = new JSDOMConfiguration(jest.fn(), jest.fn());
+ const fakeRenderAPI: any = "FAKE_RENDER_API";
+
+ // Act
+ const result = await underTest.afterEnvSetup(
+ "URL",
+ ["A", "B"],
+ fakeRenderAPI,
+ );
+
+ // Assert
+ expect(result).toBeNull();
+ });
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-environment.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-environment.test.ts
new file mode 100644
index 00000000..26eef133
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-environment.test.ts
@@ -0,0 +1,1261 @@
+import vm from "vm";
+import * as JSDOM from "jsdom";
+import * as CloseableVirtualConsole from "../closeable-virtual-console";
+import * as PatchAgainstDanglingTimers from "../patch-against-dangling-timers";
+import {JSDOMEnvironment} from "../jsdom-environment";
+
+jest.mock("jsdom");
+jest.mock("../closeable-virtual-console");
+jest.mock("../patch-against-dangling-timers");
+
+describe("JSDOMEnvironment", () => {
+ beforeEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe("#constructor", () => {
+ it("should throw if configuration is not provided", () => {
+ // Arrange
+
+ // Act
+ const underTest = () => new JSDOMEnvironment(null as any);
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
+ `"Must provide environment configuration"`,
+ );
+ });
+ });
+
+ describe("#render", () => {
+ it("should get a resource loader", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest.fn(),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeConfiguration.getResourceLoader).toHaveBeenCalledWith(
+ "URL",
+ fakeRenderAPI,
+ );
+ });
+
+ it("should retrieve the list of files to be downloaded", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeLoader: any = {
+ fetch: jest.fn(),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest.fn().mockReturnValue(fakeLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeConfiguration.getFileList).toHaveBeenCalledWith(
+ "URL",
+ fakeRenderAPI,
+ expect.any(Function),
+ );
+ });
+
+ it("should use the resource loader for getFileList fetching", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ getHeader: jest.fn(),
+ logger: fakeLogger,
+ };
+ const fakeLoader: any = {
+ fetch: jest.fn(),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest.fn().mockReturnValue(fakeLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Act
+ const fetchFn = fakeConfiguration.getFileList.mock.calls[0][2];
+ fetchFn("SOME_URL");
+
+ // Assert
+ expect(fakeLoader.fetch).toHaveBeenCalledWith("SOME_URL", {});
+ });
+
+ it("should trace the file acquisition phase", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const traceSpy = jest.fn().mockReturnValue(fakeTraceSession);
+ const getFileListSpy = jest.fn().mockResolvedValue([]);
+ const fakeRenderAPI: any = {
+ trace: traceSpy,
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: getFileListSpy,
+ getResourceLoader: jest.fn(),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(traceSpy).toHaveBeenCalledBefore(getFileListSpy);
+ });
+
+ it("should fetch the files via the resource loader", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest
+ .fn()
+ .mockImplementation((f: any) =>
+ Promise.resolve(`FETCHED: ${f}`),
+ ),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest
+ .fn()
+ .mockResolvedValue(["filea", "fileb", "filec"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeResourceLoader.fetch).toHaveBeenCalledWith("filea", {});
+ expect(fakeResourceLoader.fetch).toHaveBeenCalledWith("fileb", {});
+ expect(fakeResourceLoader.fetch).toHaveBeenCalledWith("filec", {});
+ });
+
+ it("should throw if file fetch returns null", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockReturnValue(null),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["BAD_FILE"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const env = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = env.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Unable to retrieve BAD_FILE. ResourceLoader returned null."`,
+ );
+ });
+
+ it("should end the trace session if file acquisition throws", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(null),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["BAD_FILE"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const env = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await env.render("URL", fakeRenderAPI).catch(() => {
+ /* NOTHING */
+ });
+
+ // Assert
+ expect(fakeTraceSession.end).toHaveBeenCalled();
+ });
+
+ it("should end the trace session if file acquisition succeeds", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest
+ .fn()
+ .mockImplementation((f: any) =>
+ Promise.resolve(`FETCHED: ${f}`),
+ ),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest
+ .fn()
+ .mockResolvedValue(["filea", "fileb", "filec"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ const env = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await env.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeTraceSession.end).toHaveBeenCalled();
+ });
+
+ it("should create JSDOM instance for render location", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {};
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const jsdomSpy = jest.spyOn(JSDOM, "JSDOM");
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(jsdomSpy).toHaveBeenCalledWith(
+ "
",
+ {
+ url: "URL",
+ runScripts: "dangerously",
+ resources: fakeResourceLoader,
+ pretendToBeVisual: true,
+ virtualConsole: {fakeConsole: "FAKE_CONSOLE"},
+ },
+ );
+ });
+
+ it("should close JSDOM window on rejection", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {};
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow = {
+ close: jest.fn(),
+ } as const;
+ const fakeJSDOM: any = {
+ window: fakeWindow,
+ getInternalVMContext: jest.fn().mockReturnValue(fakeWindow),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowError();
+ expect(fakeJSDOM.window.close).toHaveBeenCalled();
+ });
+
+ it("should patch the JSDOM instance against dangling timers", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {};
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const patchSpy = jest.spyOn(
+ PatchAgainstDanglingTimers,
+ "patchAgainstDanglingTimers",
+ );
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(patchSpy).toHaveBeenCalledWith(fakeWindow);
+ });
+
+ it("should close the dangling timer gate on rejection", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {};
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const fakeGate: any = {
+ close: jest.fn(),
+ } as const;
+ jest.spyOn(
+ PatchAgainstDanglingTimers,
+ "patchAgainstDanglingTimers",
+ ).mockReturnValue(fakeGate);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowError();
+ expect(fakeGate.close).toHaveBeenCalled();
+ });
+
+ it("should call afterEnvSetup", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(Buffer.from("CONTENT")),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["A", "B", "C"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const underTest = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await underTest.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeConfiguration.afterEnvSetup).toHaveBeenCalledWith(
+ "URL",
+ ["A", "B", "C"],
+ fakeRenderAPI,
+ fakeJSDOM.window,
+ );
+ });
+
+ it("should invoke the closeable returned by afterEnvSetup on rejection", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {};
+ const afterEnvCloseable = {
+ close: jest.fn(),
+ } as const;
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue([]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn().mockResolvedValue(afterEnvCloseable),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowError();
+ expect(afterEnvCloseable.close).toHaveBeenCalled();
+ });
+
+ it("should execute the downloaded files in order in the JSDOM VM context", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockImplementation((f: any) => {
+ switch (f) {
+ case "filea":
+ return Promise.resolve(`window["gubbins"] = 42;`);
+
+ case "fileb":
+ return Promise.resolve(
+ `window["gubbins"] = 2 * window["gubbins"];`,
+ );
+ }
+ throw new Error(`Unexpected file: ${f}`);
+ }),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea", "fileb"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ try {
+ await environment.render("URL", fakeRenderAPI);
+ } catch (e: any) {
+ /**
+ * We care about the expectation below.
+ */
+ }
+
+ // Assert
+ expect(fakeWindow).toMatchObject({
+ gubbins: 84,
+ });
+ });
+
+ it("should throw if no callback is registered", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockImplementation((f: any) => {
+ switch (f) {
+ case "filea":
+ return Promise.resolve(`window["gubbins"] = 42;`);
+
+ case "fileb":
+ return Promise.resolve(
+ `window["gubbins"] = 2 * window["gubbins"];`,
+ );
+ }
+ throw new Error(`Unexpected file: ${f}`);
+ }),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea", "fileb"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"No render callback was registered."`,
+ );
+ });
+
+ it("should invoke the registered render method and return the result", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const result = await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(result).toEqual({
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ });
+ });
+
+ it("should close JSDOM window on success", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {
+ close: jest.fn(),
+ };
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(fakeWindow.close).toHaveBeenCalled();
+ });
+
+ it("should close the dangling timer gate on success", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const fakeGate: any = {
+ close: jest.fn(),
+ } as const;
+ jest.spyOn(
+ PatchAgainstDanglingTimers,
+ "patchAgainstDanglingTimers",
+ ).mockReturnValue(fakeGate);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(fakeGate.close).toHaveBeenCalled();
+ });
+
+ it("should invoke the closeable returned by afterEnvSetup on success", async () => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const afterEnvCloseable = {
+ close: jest.fn(),
+ } as const;
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn().mockResolvedValue(afterEnvCloseable),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(afterEnvCloseable.close).toHaveBeenCalled();
+ });
+
+ it("should log closeable errors", async () => {
+ // Arrange
+ const fakeLogger: any = {
+ error: jest.fn(),
+ };
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const afterEnvCloseable = {
+ close: () => {
+ throw new Error("AFTER ENV GO BOOM ON CLOSE!");
+ },
+ } as const;
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn().mockResolvedValue(afterEnvCloseable),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {
+ close: jest.fn(),
+ };
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const fakeGate: any = {
+ close: jest.fn(),
+ } as const;
+ jest.spyOn(
+ PatchAgainstDanglingTimers,
+ "patchAgainstDanglingTimers",
+ ).mockReturnValue(fakeGate);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(fakeLogger.error).toHaveBeenCalledWith(
+ "Closeable encountered an error: Error: AFTER ENV GO BOOM ON CLOSE!",
+ expect.any(Object),
+ );
+ });
+
+ it("should close all non-erroring closeables", async () => {
+ // Arrange
+ const fakeLogger: any = {
+ error: jest.fn(),
+ };
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return {
+ body: "THIS IS A RENDER!",
+ status: 200,
+ headers: {},
+ };
+}
+
+window["__register__"](fakeRender);
+`),
+ close: jest.fn(),
+ };
+ const afterEnvCloseable = {
+ close: jest
+ .fn()
+ .mockRejectedValue(
+ new Error("AFTER ENV GO BOOM ON CLOSE!"),
+ ),
+ } as const;
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn().mockResolvedValue(afterEnvCloseable),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(() => ({fakeConsole: "FAKE_CONSOLE"} as any));
+ const fakeWindow: any = {
+ close: jest.fn(),
+ };
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const fakeGate: any = {
+ close: jest.fn(),
+ } as const;
+ jest.spyOn(
+ PatchAgainstDanglingTimers,
+ "patchAgainstDanglingTimers",
+ ).mockReturnValue(fakeGate);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ await environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ expect(fakeWindow.close).toHaveBeenCalled();
+ expect(fakeGate.close).toHaveBeenCalled();
+ expect(fakeResourceLoader.close).toHaveBeenCalled();
+ });
+
+ it.each([
+ undefined,
+ null,
+ "THIS IS NOT CORRECT",
+ {status: 200, headers: {}},
+ {body: "NEED MORE THAN THIS"},
+ {body: "THIS HELPS BUT WHERE ARE THE HEADERS", status: 200},
+ ])(
+ "should reject if result is malformed (%s)",
+ async (testResult: any) => {
+ // Arrange
+ const fakeLogger: any = "FAKE_LOGGER";
+ const fakeTraceSession: any = {
+ end: jest.fn(),
+ addLabel: jest.fn(),
+ };
+ const fakeRenderAPI: any = {
+ trace: jest.fn().mockReturnValue(fakeTraceSession),
+ headers: {},
+ logger: fakeLogger,
+ };
+ const fakeResourceLoader: any = {
+ fetch: jest.fn().mockResolvedValue(`
+function fakeRender() {
+ return ${JSON.stringify(testResult)};
+}
+
+window["__register__"](fakeRender);
+`),
+ };
+ const fakeConfiguration = {
+ registrationCallbackName: "__register__",
+ getFileList: jest.fn().mockResolvedValue(["filea"]),
+ getResourceLoader: jest
+ .fn()
+ .mockReturnValue(fakeResourceLoader),
+ afterEnvSetup: jest.fn(),
+ } as const;
+ jest.spyOn(
+ CloseableVirtualConsole,
+ "CloseableVirtualConsole",
+ ).mockImplementation(
+ () => ({fakeConsole: "FAKE_CONSOLE"} as any),
+ );
+ const fakeWindow: any = {};
+ fakeWindow.window = fakeWindow;
+ const fakeJSDOM: any = {
+ window: vm.createContext(fakeWindow),
+ getInternalVMContext: jest
+ .fn()
+ .mockImplementation(function (this: any) {
+ // This is a valid use of this for our scenario.
+ // eslint-disable-next-line @babel/no-invalid-this
+ return this.window;
+ }),
+ } as const;
+ jest.spyOn(JSDOM, "JSDOM").mockReturnValue(fakeJSDOM);
+ const environment = new JSDOMEnvironment(fakeConfiguration);
+
+ // Act
+ const underTest = environment.render("URL", fakeRenderAPI);
+
+ // Assert
+ await expect(underTest).rejects.toThrowErrorMatchingSnapshot();
+ },
+ );
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-file-resource-loader.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-file-resource-loader.test.ts
new file mode 100644
index 00000000..4f3afb33
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-file-resource-loader.test.ts
@@ -0,0 +1,68 @@
+import path from "path";
+import * as JSDOM from "jsdom";
+
+import * as ApplyAbortablePromisesPatch from "../apply-abortable-promises-patch";
+
+import {JSDOMFileResourceLoader} from "../jsdom-file-resource-loader";
+
+jest.mock("jsdom");
+jest.mock("../apply-abortable-promises-patch");
+
+describe("JSDOMFileResourceLoader", () => {
+ describe("#constructor", () => {
+ it("should invoke applyAbortablePromisesPatch before super()", () => {
+ // Arrange
+ const applyAbortablePromisesPatchSpy = jest.spyOn(
+ ApplyAbortablePromisesPatch,
+ "applyAbortablePromisesPatch",
+ );
+ const resourceLoaderSpy = jest.spyOn(
+ JSDOM,
+ "ResourceLoader",
+ );
+
+ // Act
+ // eslint-disable-next-line no-new
+ new JSDOMFileResourceLoader(__dirname);
+
+ // Assert
+ expect(applyAbortablePromisesPatchSpy).toHaveBeenCalledBefore(
+ resourceLoaderSpy,
+ );
+ });
+
+ it("should throw if rootDir is omitted", () => {
+ // Arrange
+
+ // Act
+ const underTest = () => new JSDOMFileResourceLoader(null as any);
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
+ `"Root folder cannot be found"`,
+ );
+ });
+ });
+
+ describe("#fetch", () => {
+ it.each([
+ "http://example.com/__data__/file-loader-test.txt",
+ "./__data__/file-loader-test.txt",
+ path.normalize(
+ path.join(__dirname, "./__data__/file-loader-test.txt"),
+ ),
+ ])("should read file", async (filePath: any) => {
+ // Arrange
+ const underTest = new JSDOMFileResourceLoader(__dirname);
+
+ // Act
+ const result: any = await underTest.fetch(filePath, {});
+
+ // Assert
+ expect(result).toBeInstanceOf(Buffer);
+ expect(result.toString()).toBe(
+ "THIS IS TEST CONTENT FOR THE SNAPSHOT!",
+ );
+ });
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-resource-loader.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-resource-loader.test.ts
new file mode 100644
index 00000000..514eee8e
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-resource-loader.test.ts
@@ -0,0 +1,515 @@
+import "jest-extended";
+import * as JSDOM from "jsdom";
+
+import * as WSServer from "@khanacademy/wonder-stuff-server";
+import * as WSRenderServer from "@khanacademy/wonder-stuff-render-server";
+import * as ApplyAbortablePromisesPatch from "../apply-abortable-promises-patch";
+
+import {JSDOMResourceLoader} from "../jsdom-resource-loader";
+
+jest.mock("jsdom");
+jest.mock("@khanacademy/wonder-stuff-server");
+jest.mock("@khanacademy/wonder-stuff-render-server");
+jest.mock("../apply-abortable-promises-patch");
+
+describe("JSDOMResourceLoader", () => {
+ describe("#constructor", () => {
+ it("should invoke applyAbortablePromisesPatch before super()", () => {
+ // Arrange
+ const fakeRenderAPI: any = {};
+ const applyAbortablePromisesPatchSpy = jest.spyOn(
+ ApplyAbortablePromisesPatch,
+ "applyAbortablePromisesPatch",
+ );
+ const resourceLoaderSpy = jest.spyOn(
+ JSDOM,
+ "ResourceLoader",
+ );
+
+ // Act
+ // eslint-disable-next-line no-new
+ new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Assert
+ expect(applyAbortablePromisesPatchSpy).toHaveBeenCalledBefore(
+ resourceLoaderSpy,
+ );
+ });
+
+ it("should throw if renderAPI is omitted", () => {
+ // Arrange
+
+ // Act
+ const underTest = () => new JSDOMResourceLoader(null as any);
+
+ // Assert
+ expect(underTest).toThrowErrorMatchingInlineSnapshot(
+ `"Must provide render API."`,
+ );
+ });
+
+ it("should initialize isActive to true", () => {
+ // Arrange
+ const fakeRenderAPI: any = {};
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result = underTest.isActive;
+
+ // Assert
+ expect(result).toBeTrue();
+ });
+ });
+
+ describe("EMPTY_RESPONSE", () => {
+ it("should resolve to empty buffer", async () => {
+ // Arrange
+
+ // Act
+ const result = await JSDOMResourceLoader.EMPTY_RESPONSE;
+
+ // Assert
+ expect(result).toBeInstanceOf(Buffer);
+ expect(result.toString()).toBeEmpty();
+ });
+ });
+
+ describe("#close", () => {
+ it("should set isActive to false", () => {
+ // Arrange
+ const fakeRenderAPI: any = {};
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ underTest.close();
+
+ // Assert
+ expect(underTest.isActive).toBeFalse();
+ });
+
+ it("should destroy agents it created", () => {
+ // Arrange
+ const fakePromise: any = {
+ then: jest.fn().mockReturnThis(),
+ } as const;
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ fakePromise,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeAgent: any = {
+ destroy: jest.fn(),
+ } as const;
+ jest.spyOn(WSServer, "getAgentForURL").mockReturnValue(fakeAgent);
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+ underTest.fetch("http://example.com/test.js?p=1", {});
+
+ // Act
+ underTest.close();
+
+ // Assert
+ expect(fakeAgent.destroy).toHaveBeenCalled();
+ });
+ });
+
+ describe("#fetch", () => {
+ describe("called before close()", () => {
+ it("should return EMPTY_RESPONSE for non-JS file", () => {
+ // Arrange
+ const fakeRenderAPI: any = {
+ logger: {
+ silly: jest.fn(),
+ },
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result = underTest.fetch(
+ "http://example.com/test.png",
+ {},
+ );
+
+ // Assert
+ expect(result).toStrictEqual(
+ JSDOMResourceLoader.EMPTY_RESPONSE,
+ );
+ });
+
+ it("should not invoke request for non-JS file", () => {
+ // Arrange
+ const requestSpy = jest.spyOn(
+ WSRenderServer.Requests,
+ "request",
+ );
+ const fakeRenderAPI: any = {
+ logger: {
+ silly: jest.fn(),
+ },
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ underTest.fetch("http://example.com/test.png", {});
+
+ // Assert
+ expect(requestSpy).not.toHaveBeenCalled();
+ });
+
+ it("should invoke request for JS file", () => {
+ // Arrange
+ const fakePromise: any = {
+ then: jest.fn().mockReturnThis(),
+ } as const;
+ const requestSpy = jest
+ .spyOn(WSRenderServer.Requests, "request")
+ .mockReturnValue(fakePromise);
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeAgent: any = {
+ destroy: jest.fn(),
+ } as const;
+ jest.spyOn(WSServer, "getAgentForURL").mockReturnValue(
+ fakeAgent,
+ );
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ underTest.fetch("http://example.com/test.js?p=1", {});
+
+ // Assert
+ expect(requestSpy).toHaveBeenCalledWith(
+ "FAKE_LOGGER",
+ "http://example.com/test.js?p=1",
+ expect.objectContaining({
+ agent: fakeAgent,
+ ...WSRenderServer.Requests.DefaultRequestOptions,
+ }),
+ );
+ });
+
+ it("should have abort function that invokes abort on request", () => {
+ // Arrange
+ const fakePromise = {
+ then: jest.fn().mockReturnThis(),
+ } as const;
+ const fakeRequest: any = {
+ then: jest.fn().mockReturnValue(fakePromise),
+ abort: jest.fn(),
+ aborted: "FAKE_ABORTED_VALUE",
+ } as const;
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ fakeRequest,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result: any = underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+ result.abort();
+
+ // Assert
+ expect(fakeRequest.abort).toHaveBeenCalled();
+ });
+
+ it("should have aborted property that gets aborted property of request", () => {
+ // Arrange
+ const fakePromise = {
+ then: jest.fn().mockReturnThis(),
+ } as const;
+ const fakeRequest: any = {
+ then: jest.fn().mockReturnValue(fakePromise),
+ abort: jest.fn(),
+ aborted: "FAKE_ABORTED_VALUE",
+ } as const;
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ fakeRequest,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result: any = underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+
+ // Assert
+ expect(result.aborted).toBe(fakeRequest.aborted);
+ });
+
+ it("should resolve with buffer of content", async () => {
+ // Arrange
+ const fakeResponse: any = {
+ text: "RESPONSE",
+ } as const;
+ jest.spyOn(
+ WSRenderServer.Requests,
+ "request",
+ ).mockResolvedValue(fakeResponse);
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result: any = await underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+
+ // Assert
+ expect(result).toBeInstanceOf(Buffer);
+ expect(result.toString()).toBe("RESPONSE");
+ });
+
+ it("should invoke custom handler if one was provided", async () => {
+ // Arrange
+ const fakeResponse: any = Promise.resolve({
+ text: "RESPONSE",
+ });
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ fakeResponse,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeFetchOptions: any = {
+ options: "ARE FAKE",
+ } as const;
+ const customHandler = jest
+ .fn()
+ .mockResolvedValue(Buffer.from("CUSTOM"));
+ const underTest = new JSDOMResourceLoader(
+ fakeRenderAPI,
+ undefined,
+ customHandler,
+ );
+
+ // Act
+ await underTest.fetch(
+ "http://example.com/test.js?p=1",
+ fakeFetchOptions,
+ );
+
+ // Assert
+ expect(customHandler).toHaveBeenCalledWith(
+ fakeResponse,
+ "http://example.com/test.js?p=1",
+ fakeFetchOptions,
+ );
+ });
+
+ it("should return result of custom handler if one was provided", async () => {
+ // Arrange
+ const fakeResponse: any = Promise.resolve({
+ text: "RESPONSE",
+ });
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ fakeResponse,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeFetchOptions: any = {
+ options: "ARE FAKE",
+ } as const;
+ const customHandler = jest
+ .fn()
+ .mockResolvedValue(Buffer.from("CUSTOM"));
+ const underTest = new JSDOMResourceLoader(
+ fakeRenderAPI,
+ undefined,
+ customHandler,
+ );
+
+ // Act
+ const result: any = await underTest.fetch(
+ "http://example.com/test.js?p=1",
+ fakeFetchOptions,
+ );
+
+ // Assert
+ expect(result.toString()).toBe("CUSTOM");
+ });
+
+ describe("but resolves after close()", () => {
+ it("should resolve to an empty buffer", async () => {
+ // Arrange
+ const fakeResponse: any = {
+ text: "RESPONSE",
+ } as const;
+ jest.spyOn(
+ WSRenderServer.Requests,
+ "request",
+ ).mockResolvedValue(fakeResponse);
+ const fakeLogger = {
+ info: jest.fn(),
+ } as const;
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeAgent: any = {
+ destroy: jest.fn(),
+ } as const;
+ jest.spyOn(WSServer, "getAgentForURL").mockReturnValue(
+ fakeAgent,
+ );
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const response: any = underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+ underTest.close();
+ const result = await response;
+
+ // Assert
+ expect(result).toBeInstanceOf(Buffer);
+ expect(result.toString()).toBe("");
+ });
+
+ it("should log info", async () => {
+ // Arrange
+ const fakeResponse: any = {
+ text: "RESPONSE",
+ } as const;
+ jest.spyOn(
+ WSRenderServer.Requests,
+ "request",
+ ).mockResolvedValue(fakeResponse);
+ const fakeLogger = {
+ info: jest.fn(),
+ } as const;
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const fakeAgent: any = {
+ destroy: jest.fn(),
+ } as const;
+ jest.spyOn(WSServer, "getAgentForURL").mockReturnValue(
+ fakeAgent,
+ );
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const response: any = underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+ underTest.close();
+ await response;
+
+ // Assert
+ expect(fakeLogger.info).toHaveBeenCalledWith(
+ "File requested but never used: http://example.com/test.js?p=1",
+ );
+ });
+ });
+ });
+
+ it("should resolve to empty response for aborted requests", async () => {
+ // Arrange
+ const abortablePromise: any = new Promise(
+ (resolve: any, reject: any) => {
+ resolve({text: "THIS IS NOT EMPTY"});
+ },
+ );
+ abortablePromise.abort = jest.fn();
+ abortablePromise.aborted = true;
+ jest.spyOn(WSRenderServer.Requests, "request").mockReturnValue(
+ abortablePromise,
+ );
+ const fakeLogger = "FAKE_LOGGER";
+ const fakeRenderAPI: any = {
+ logger: fakeLogger,
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+
+ // Act
+ const result: any = await underTest.fetch(
+ "http://example.com/test.js?p=1",
+ {},
+ );
+
+ // Assert
+ expect(result).toBeEmpty();
+ });
+
+ describe("called after close()", () => {
+ it("should log a warning", () => {
+ // Arrange
+ const fakeRenderAPI: any = {
+ logger: {
+ warn: jest.fn(),
+ },
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+ underTest.close();
+
+ // Act
+ underTest.fetch("http://example.com/test.js", {});
+
+ // Assert
+ expect(fakeRenderAPI.logger.warn).toHaveBeenCalledWith(
+ "File fetch attempted after resource loader close: http://example.com/test.js",
+ );
+ });
+
+ it("should not log a warning for inline data", () => {
+ // Arrange
+ const fakeRenderAPI: any = {
+ logger: {
+ warn: jest.fn(),
+ },
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+ underTest.close();
+
+ // Act
+ underTest.fetch("data:inline datary things like an SVG", {});
+
+ // Assert
+ expect(fakeRenderAPI.logger.warn).not.toHaveBeenCalled();
+ });
+
+ it("should return EMPTY_RESPONSE", () => {
+ // Arrange
+ const fakeRenderAPI: any = {
+ logger: {
+ warn: jest.fn(),
+ },
+ };
+ const underTest = new JSDOMResourceLoader(fakeRenderAPI);
+ underTest.close();
+
+ // Act
+ const result = underTest.fetch(
+ "http://example.com/test.js",
+ {},
+ );
+
+ // Assert
+ expect(result).toStrictEqual(
+ JSDOMResourceLoader.EMPTY_RESPONSE,
+ );
+ });
+ });
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/__tests__/patch-against-dangling-timers.test.ts b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/patch-against-dangling-timers.test.ts
new file mode 100644
index 00000000..75171ebe
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/__tests__/patch-against-dangling-timers.test.ts
@@ -0,0 +1,481 @@
+import {patchAgainstDanglingTimers} from "../patch-against-dangling-timers";
+import type {IGate} from "../types";
+
+describe("#patchAgainstDanglingTimers", () => {
+ beforeEach(() => {
+ // Let's silence the actual console.warn calls for a cleaner test
+ // output.
+ jest.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ it("should return a gate for controlling the timers", () => {
+ // Arrange
+ const fakeWindow: any = {
+ setTimeout: jest.fn(),
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ const result: IGate = patchAgainstDanglingTimers(fakeWindow);
+
+ // Assert
+ expect(result).toStrictEqual({
+ open: expect.any(Function),
+ close: expect.any(Function),
+ isOpen: true,
+ });
+ });
+
+ describe("#setTimeout", () => {
+ it("should be patched", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+
+ // Assert
+ expect(fakeWindow.setTimeout).not.toBe(fakeSetTimeout);
+ });
+
+ it("should call the original function", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ fakeWindow.setTimeout(jest.fn(), 0);
+
+ // Assert
+ expect(fakeSetTimeout).toHaveBeenCalledWith(
+ expect.any(Function),
+ 0,
+ );
+ });
+
+ it("should return the result of original function", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn().mockReturnValue(42);
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ const result = fakeWindow.setTimeout(jest.fn(), 0);
+
+ // Assert
+ expect(result).toBe(42);
+ });
+
+ describe("gate open", () => {
+ it("should execute callback", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.open();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setTimeout(actualCallback);
+ const registeredCallback = fakeSetTimeout.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("gate closed", () => {
+ it("should not execute callback", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setTimeout(actualCallback);
+ const registeredCallback = fakeSetTimeout.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).not.toHaveBeenCalled();
+ });
+
+ it("should warn about dangling timer", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setTimeout(actualCallback);
+ const registeredCallback = fakeSetTimeout.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Dangling timer(s) detected",
+ );
+ });
+
+ it("should only warn once about a dangling timer", () => {
+ // Arrange
+ const fakeSetTimeout = jest.fn();
+ const fakeWindow: any = {
+ setTimeout: fakeSetTimeout,
+ setInterval: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setTimeout(actualCallback);
+ const registeredCallback = fakeSetTimeout.mock.calls[0][0];
+ registeredCallback();
+ registeredCallback();
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe("#setInterval", () => {
+ it("should be patched", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+
+ // Assert
+ expect(fakeWindow.setInterval).not.toBe(fakeSetInterval);
+ });
+
+ it("should call the original function", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ fakeWindow.setInterval(jest.fn(), 100);
+
+ // Assert
+ expect(fakeSetInterval).toHaveBeenCalledWith(
+ expect.any(Function),
+ 100,
+ );
+ });
+
+ it("should return the result of original function", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn().mockReturnValue(1000000);
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ const result = fakeWindow.setInterval(jest.fn(), 100);
+
+ // Assert
+ expect(result).toBe(1000000);
+ });
+
+ describe("gate open", () => {
+ it("should execute callback", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.open();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setInterval(actualCallback);
+ const registeredCallback = fakeSetInterval.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("gate closed", () => {
+ it("should not execute callback", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setInterval(actualCallback);
+ const registeredCallback = fakeSetInterval.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).not.toHaveBeenCalled();
+ });
+
+ it("should warn about dangling timer", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setInterval(actualCallback);
+ const registeredCallback = fakeSetInterval.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Dangling timer(s) detected",
+ );
+ });
+
+ it("should only warn once about a dangling timer", () => {
+ // Arrange
+ const fakeSetInterval = jest.fn();
+ const fakeWindow: any = {
+ setInterval: fakeSetInterval,
+ setTimeout: jest.fn(),
+ requestAnimationFrame: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.setInterval(actualCallback);
+ const registeredCallback = fakeSetInterval.mock.calls[0][0];
+ registeredCallback();
+ registeredCallback();
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe("#requestAnimationFrame", () => {
+ it("should be patched", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setInterval: jest.fn(),
+ setTimeout: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+
+ // Assert
+ expect(fakeWindow.requestAnimationFrame).not.toBe(
+ fakeRequestAnimationFrame,
+ );
+ });
+
+ it("should call the original function", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setInterval: jest.fn(),
+ setTimeout: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ fakeWindow.requestAnimationFrame(jest.fn());
+
+ // Assert
+ expect(fakeRequestAnimationFrame).toHaveBeenCalledWith(
+ expect.any(Function),
+ );
+ });
+
+ it("should return the result of original function", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn().mockReturnValue(200);
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setInterval: jest.fn(),
+ setTimeout: jest.fn(),
+ } as const;
+
+ // Act
+ patchAgainstDanglingTimers(fakeWindow);
+ const result = fakeWindow.requestAnimationFrame(jest.fn());
+
+ // Assert
+ expect(result).toBe(200);
+ });
+
+ describe("gate open", () => {
+ it("should execute callback", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setTimeout: jest.fn(),
+ setInterval: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.open();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.requestAnimationFrame(actualCallback);
+ const registeredCallback =
+ fakeRequestAnimationFrame.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("gate closed", () => {
+ it("should not execute callback", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setTimeout: jest.fn(),
+ setInterval: jest.fn(),
+ } as const;
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.requestAnimationFrame(actualCallback);
+ const registeredCallback =
+ fakeRequestAnimationFrame.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(actualCallback).not.toHaveBeenCalled();
+ });
+
+ it("should warn about dangling timer", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setTimeout: jest.fn(),
+ setInterval: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.requestAnimationFrame(actualCallback);
+ const registeredCallback =
+ fakeRequestAnimationFrame.mock.calls[0][0];
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Dangling timer(s) detected",
+ );
+ });
+
+ it("should only warn once about a dangling timer", () => {
+ // Arrange
+ const fakeRequestAnimationFrame = jest.fn();
+ const fakeWindow: any = {
+ requestAnimationFrame: fakeRequestAnimationFrame,
+ setTimeout: jest.fn(),
+ setInterval: jest.fn(),
+ } as const;
+ const warnSpy = jest.spyOn(console, "warn");
+ const gate = patchAgainstDanglingTimers(fakeWindow);
+ gate.close();
+ const actualCallback = jest.fn();
+
+ // Act
+ fakeWindow.requestAnimationFrame(actualCallback);
+ const registeredCallback =
+ fakeRequestAnimationFrame.mock.calls[0][0];
+ registeredCallback();
+ registeredCallback();
+ registeredCallback();
+
+ // Assert
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+});
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/apply-abortable-promises-patch.ts b/packages/wonder-stuff-render-environment-jsdom/src/apply-abortable-promises-patch.ts
new file mode 100644
index 00000000..eb5d7740
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/apply-abortable-promises-patch.ts
@@ -0,0 +1,47 @@
+const patchedMarker = "__patched__";
+
+/**
+ * JSDOM assumes that all fetchs are abortable. However, this is not always
+ * the case, due to how some can be regular promises.
+ *
+ * Though we try to mitigate this in our various request implementations, this
+ * is our last chance catch all that ensures the promise prototype has an abort
+ * call.
+ *
+ * By making sure this exists, JSDOM does not throw when closing down an
+ * instance and we can guarantee that all truly abortable requests are actually
+ * aborted.
+ */
+export const applyAbortablePromisesPatch = (force = false): void => {
+ if (
+ !force &&
+ // @ts-expect-error We know that this doesn't exist on the promise type
+ // but it does if we already patched it.
+ Promise.prototype.abort &&
+ // @ts-expect-error We know that this doesn't exist on the promise type
+ // but it does if we already patched it.
+ Promise.prototype.abort[patchedMarker]
+ ) {
+ return;
+ }
+
+ // @ts-expect-error We know that this doesn't exist on the promise type
+ // but it does if we already patched it.
+ delete Promise.prototype.abort;
+
+ /**
+ * Make a noop and tag it as our patched version (that way we prevent
+ * patching more than once).
+ */
+ const ourAbort = () => {
+ /* empty */
+ };
+ // @ts-expect-error We know that the inferred type is wrong here and
+ // it's not worth convincing TS with a better type, so just suppress it.
+ ourAbort[patchedMarker] = true;
+
+ // @ts-expect-error We know this isn't on the Promise type. We could
+ // consider extending the type to add it at some point, but for now,
+ // suppress.
+ Promise.prototype.abort = ourAbort;
+};
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/closeable-virtual-console.ts b/packages/wonder-stuff-render-environment-jsdom/src/closeable-virtual-console.ts
new file mode 100644
index 00000000..6da5fb32
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/closeable-virtual-console.ts
@@ -0,0 +1,71 @@
+import {VirtualConsole} from "jsdom";
+import {Errors} from "@khanacademy/wonder-stuff-core";
+import type {Logger} from "@khanacademy/wonder-stuff-server";
+import {extractError} from "@khanacademy/wonder-stuff-render-server";
+import type {ICloseable} from "@khanacademy/wonder-stuff-render-server";
+
+export class CloseableVirtualConsole
+ extends VirtualConsole
+ implements ICloseable
+{
+ _closed: boolean;
+
+ constructor(logger: Logger) {
+ super();
+ this._closed = false;
+
+ this.on("jsdomError", (e: Error) => {
+ if (this._closed) {
+ // We are closed. No logging.
+ return;
+ }
+ if (e.message.indexOf("Could not load img") >= 0) {
+ // We know that images cannot load. We're deliberately blocking
+ // them.
+ return;
+ }
+ const simplifiedError = extractError(e);
+ logger.error(`JSDOM jsdomError:${simplifiedError.error || ""}`, {
+ ...simplifiedError,
+ kind: Errors.Internal,
+ });
+ });
+
+ /**
+ * NOTE(somewhatabstract): We pass args array as the metadata parameter for
+ * winston log. We don't worry about adding the error kind here; we mark
+ * these as Errors.Internal automatically if they don't already include a
+ * kind.
+ */
+ this.on(
+ "error",
+ (message, ...args) =>
+ !this._closed && logger.error(`JSDOM error:${message}`, {args}),
+ );
+
+ /**
+ * We log all other things as `silly`, since they are generally only useful
+ * to us when we're developing/debugging issues locally, and not in
+ * production. We could add some way to turn this on in production
+ * temporarily (like a temporary "elevate log level" query param) if
+ * we find that will be useful, but I haven't encountered an issue that
+ * needed these in production yet; they're just noise.
+ */
+ const passthruLog = (method: "warn" | "info" | "log" | "debug") => {
+ this.on(
+ method,
+ (message, ...args) =>
+ !this._closed &&
+ logger.silly(`JSDOM ${method}:${message}`, {args}),
+ );
+ };
+ passthruLog("warn");
+ passthruLog("info");
+ passthruLog("log");
+ passthruLog("debug");
+ }
+
+ close() {
+ this._closed = true;
+ }
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/index.ts b/packages/wonder-stuff-render-environment-jsdom/src/index.ts
index e69de29b..21c3e7a8 100644
--- a/packages/wonder-stuff-render-environment-jsdom/src/index.ts
+++ b/packages/wonder-stuff-render-environment-jsdom/src/index.ts
@@ -0,0 +1,6 @@
+export {JSDOMConfiguration as Configuration} from "./jsdom-configuration";
+export {JSDOMEnvironment as Environment} from "./jsdom-environment";
+export {JSDOMResourceLoader as ResourceLoader} from "./jsdom-resource-loader";
+export {JSDOMFileResourceLoader as FileResourceLoader} from "./jsdom-file-resource-loader";
+
+export type {IJSDOMConfiguration} from "./types";
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/jsdom-configuration.ts b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-configuration.ts
new file mode 100644
index 00000000..07294607
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-configuration.ts
@@ -0,0 +1,94 @@
+import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
+import type {
+ RenderAPI,
+ ICloseable,
+} from "@khanacademy/wonder-stuff-render-server";
+import type {IJSDOMConfiguration, CloseableResourceLoader} from "./types";
+
+/**
+ * Utility for creating a valid configuration to use with the JSDOM environment.
+ */
+export class JSDOMConfiguration implements IJSDOMConfiguration {
+ readonly registrationCallbackName: string;
+ readonly getFileList: (
+ url: string,
+ renderAPI: RenderAPI,
+ fetchFn: (url: string) => Promise | null | undefined,
+ ) => Promise>;
+ readonly getResourceLoader: (
+ url: string,
+ renderAPI: RenderAPI,
+ ) => CloseableResourceLoader;
+ readonly afterEnvSetup: (
+ url: string,
+ fileURLs: ReadonlyArray,
+ renderAPI: RenderAPI,
+ vmContext?: any,
+ ) => Promise;
+
+ /**
+ * Create a configuration for use with the JSDOM environment.
+ *
+ * @param {(url: string, renderAPI: RenderAPI) => Promise>} getFileList
+ * Callback that should return a promise for the list of JavaScript files
+ * the environment must execute in order to produce a result for the given
+ * render request.
+ * @param {(url: string, renderAPI: RenderAPI) => ResourceLoader} getResourceLoader
+ * Callback that should return a JSDOM resource loader for the given
+ * request. We must call this per render so that logging is appropriately
+ * channeled for the request being made.
+ * @param {(url: string, fileURLs: $ReadOnlyArray, renderAPI: RenderAPI, vmContext: any) => ?Promise} [afterEnvSetup]
+ * Callback to perform additional environment setup before the render
+ * occurs. This can optionally return an object that can add extra fields
+ * to the environment context for rendering code to access. This is useful
+ * if your render server wants to add some specific configuration, such
+ * as setting up some versions of Apollo for server-side rendering.
+ * Be careful; any functions you attach can be executed by the rendering
+ * code.
+ * @param {string} [registrationCallbackName] The name of the function
+ * that the environment should expose for client code to register for
+ * rendering. This defaults to `__jsdom_env_register`.
+ */
+ constructor(
+ getFileList: (
+ url: string,
+ renderAPI: RenderAPI,
+ fetchFn: (url: string) => Promise | null | undefined,
+ ) => Promise>,
+ getResourceLoader: (
+ url: string,
+ renderAPI: RenderAPI,
+ ) => CloseableResourceLoader,
+ afterEnvSetup?: (
+ url: string,
+ fileURLs: ReadonlyArray,
+ renderAPI: RenderAPI,
+ vmContext?: any,
+ ) => Promise,
+ registrationCallbackName = "__jsdom_env_register",
+ ) {
+ if (typeof getFileList !== "function") {
+ throw new KindError(
+ "Must provide valid callback for obtaining file list",
+ Errors.Internal,
+ );
+ }
+ if (typeof getResourceLoader !== "function") {
+ throw new KindError(
+ "Must provide valid callback for obtaining resource loader",
+ Errors.Internal,
+ );
+ }
+ if (afterEnvSetup != null && typeof afterEnvSetup !== "function") {
+ throw new KindError(
+ "Must provide valid callback for after env setup or null",
+ Errors.Internal,
+ );
+ }
+
+ this.registrationCallbackName = registrationCallbackName;
+ this.getFileList = getFileList;
+ this.getResourceLoader = getResourceLoader;
+ this.afterEnvSetup = afterEnvSetup || (() => Promise.resolve(null));
+ }
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/jsdom-environment.ts b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-environment.ts
new file mode 100644
index 00000000..d39f5f6c
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-environment.ts
@@ -0,0 +1,380 @@
+import type vm from "vm";
+import {KindError} from "@khanacademy/wonder-stuff-core";
+import {Errors} from "@khanacademy/wonder-stuff-server";
+import type {Logger, ITraceSession} from "@khanacademy/wonder-stuff-server";
+import {extractError} from "@khanacademy/wonder-stuff-render-server";
+import type {
+ AmbiguousError,
+ IRenderEnvironment,
+ RenderAPI,
+ RenderResult,
+ ICloseable,
+} from "@khanacademy/wonder-stuff-render-server";
+import type {
+ IJSDOMConfiguration,
+ CloseableResourceLoader,
+ IGate,
+} from "./types";
+
+interface RenderCallbackFn {
+ /**
+ * Method invoked to create a render result.
+ */
+ (): Promise;
+}
+
+/**
+ * A JS file.
+ */
+type JavaScriptFile = {
+ /**
+ * The content of the file.
+ */
+ readonly content: string;
+ /**
+ * The URL of the file.
+ */
+ readonly url: string;
+};
+
+type JavaScriptFiles = {
+ readonly files: ReadonlyArray;
+ readonly urls: ReadonlyArray;
+};
+
+const MinimalPage = "";
+
+/**
+ * A render environment built to support the JSDOM 16.x API.
+ */
+export class JSDOMEnvironment implements IRenderEnvironment {
+ _configuration: IJSDOMConfiguration;
+
+ /**
+ * Create a new instance of this environment.
+ *
+ * @param {IJSDOMConfiguration} configuration
+ * Configuration for the environment.
+ */
+ constructor(configuration: IJSDOMConfiguration) {
+ if (configuration == null) {
+ throw new KindError(
+ "Must provide environment configuration",
+ Errors.Internal,
+ );
+ }
+ this._configuration = configuration;
+ }
+
+ _retrieveTargetFiles: (
+ url: string,
+ renderAPI: RenderAPI,
+ resourceLoader: CloseableResourceLoader,
+ ) => Promise = async (
+ url: string,
+ renderAPI: RenderAPI,
+ resourceLoader: CloseableResourceLoader,
+ ): Promise => {
+ const traceSession: ITraceSession = renderAPI.trace(
+ "JSDOM16._retrieveTargetFiles",
+ `JSDOMEnvironment retrieving files`,
+ );
+ try {
+ /**
+ * First, we need to know what files to execute so that we can
+ * produce a render result, and we need a resource loader so that
+ * we can retrieve those files as well as support retrieving
+ * additional files within our JSDOM environment.
+ */
+ const fileURLs = await this._configuration.getFileList(
+ url,
+ renderAPI,
+ (url) => resourceLoader.fetch(url, {}),
+ );
+ traceSession.addLabel("fileCount", fileURLs.length);
+
+ /**
+ * Now let's use the resource loader to get the files.
+ * We ignore the `FetchOptions` param of resourceLoader.fetch as we
+ * have nothing to pass there.
+ */
+ return {
+ files: await Promise.all(
+ fileURLs.map((f) => {
+ const fetchResult = resourceLoader.fetch(f, {});
+ /**
+ * Resource loader's fetch can return null. It shouldn't for
+ * any of these files though, so if it does, let's raise an
+ * error!
+ */
+ if (fetchResult == null) {
+ throw new KindError(
+ `Unable to retrieve ${f}. ResourceLoader returned null.`,
+ Errors.TransientService,
+ );
+ }
+ /**
+ * No need to reconnect the abort() in this case since we
+ * won't be calling it.
+ */
+ return fetchResult.then((b) => ({
+ content: b.toString(),
+ url: f,
+ }));
+ }),
+ ),
+ urls: fileURLs,
+ };
+ } finally {
+ traceSession.end();
+ }
+ };
+
+ _closeAll(
+ closeables: Array,
+ logger: Logger,
+ ): Promise {
+ return new Promise((resolve) => {
+ /**
+ * We wrap this in a timeout to hopefully mitigate any chances
+ * of https://github.com/jsdom/jsdom/issues/1682
+ */
+ setTimeout(async () => {
+ const reportCloseableError = (e: AmbiguousError) => {
+ // We do not want to stop closing just because something
+ // errored.
+ const simplifiedError = extractError(e);
+ logger.error(
+ `Closeable encountered an error: ${
+ simplifiedError.error || ""
+ }`,
+ {
+ ...simplifiedError,
+ kind: Errors.Internal,
+ },
+ );
+ };
+ /**
+ * We want to close things. We're going to assume that
+ * things are robust to change and close everything at once.
+ * That way we shutdown as fast as we can, and any "closed"
+ * states that are set on close to gate things like wasted
+ * JS requests are properly entered as soon as possible.
+ */
+ await Promise.all(
+ closeables.map((c) => {
+ try {
+ return c?.close?.()?.catch(reportCloseableError);
+ } catch (e: any) {
+ reportCloseableError(e);
+ }
+ }),
+ );
+
+ /**
+ * Let's clear the array to make sure we're not holding
+ * on to any references unnecessarily.
+ */
+ closeables.length = 0;
+ resolve();
+ });
+ });
+ }
+
+ async _runScript(
+ vmContext: vm.Context,
+ script: string,
+ options?: vm.ScriptOptions,
+ ): Promise {
+ const {Script} = await import("vm");
+ const realScript = new Script(script, options);
+ return realScript.runInContext(vmContext);
+ }
+
+ /**
+ * Generate a render result for the given url.
+ *
+ * @param {string} url The URL that is to be rendered. This is always
+ * relative to the host and so does not contain protocol, hostname, nor port
+ * information.
+ * @param {RenderAPI} renderAPI An API of utilities for assisting with the
+ * render operation.
+ * @returns {Promise} The result of the render that is to be
+ * returned by the gateway service as the response to the render request.
+ * This includes the body of the response and the status code information.
+ */
+ render: (url: string, renderAPI: RenderAPI) => Promise =
+ async (url: string, renderAPI: RenderAPI): Promise => {
+ /**
+ * We want to tidy up nicely if there's a problem and also if the render
+ * context is closed, so let's handle that by putting closeable things
+ * into a handy list and providing a way to close them all.
+ */
+ const closeables: Array = [];
+ try {
+ /**
+ * We are going to need a resource loader so that we can obtain files
+ * both inside and outside the JSDOM VM.
+ */
+ const resourceLoader = this._configuration.getResourceLoader(
+ url,
+ renderAPI,
+ );
+ closeables.push(resourceLoader);
+
+ // Let's get those files!
+ const files = await this._retrieveTargetFiles(
+ url,
+ renderAPI,
+ resourceLoader,
+ );
+
+ /**
+ * We want a JSDOM instance for the url we want to render. This is
+ * where we setup custom resource loading and our virtual console
+ * too.
+ */
+ const {JSDOM} = await import("jsdom");
+ const {CloseableVirtualConsole} = await import(
+ "./closeable-virtual-console"
+ );
+ const virtualConsole = new CloseableVirtualConsole(
+ renderAPI.logger,
+ );
+ const jsdomInstance = new JSDOM(MinimalPage, {
+ url,
+ runScripts: "dangerously",
+ resources: resourceLoader as any,
+ pretendToBeVisual: true,
+ virtualConsole,
+ });
+ closeables.push(virtualConsole);
+ closeables.push(jsdomInstance.window);
+
+ /**
+ * OK, we know this is a JSDOM instance but we want to expose a nice
+ * wrapper. As part of that wrapper, we want to make it easier to
+ * run scripts (like our rendering JS code) within the VM context.
+ * So, let's create a helper for that.
+ *
+ * We cast the context to any, because otherwise it is typed as an
+ * empty object, which makes life annoying.
+ */
+ const vmContext: any = jsdomInstance.getInternalVMContext();
+
+ /**
+ * Next, we want to patch timers so we can make sure they don't
+ * fire after we are done (and so we can catch dangling timers if
+ * necessary). To do this, we are going to hang the timer API off
+ * the vmContext and then execute it from inside the context.
+ * Super magic.
+ */
+ const tmpFnName = "__tmp_patchTimers";
+ const {patchAgainstDanglingTimers} = await import(
+ "./patch-against-dangling-timers"
+ );
+ vmContext[tmpFnName] = patchAgainstDanglingTimers;
+ const timerGateAPI: IGate = await this._runScript(
+ vmContext,
+ `${tmpFnName}(window);`,
+ );
+ delete vmContext[tmpFnName];
+ closeables.push(timerGateAPI);
+
+ /**
+ * At this point, we give our configuration an opportunity to
+ * modify the render context and capture the return result, which
+ * can be used to tidy up after we're done.
+ */
+ const afterRenderTidyUp =
+ await this._configuration.afterEnvSetup(
+ url,
+ files.urls,
+ renderAPI,
+ vmContext,
+ );
+ closeables.push(afterRenderTidyUp);
+
+ /**
+ * At this point, before loading the files for rendering, we must
+ * configure the registration point in our render context.
+ */
+ const {registrationCallbackName} = this._configuration;
+ const registeredCbName = "__registeredCallback";
+ vmContext[registrationCallbackName] = (
+ cb: RenderCallbackFn,
+ ): void => {
+ vmContext[registrationCallbackName][registeredCbName] = cb;
+ };
+ closeables.push({
+ close: () => {
+ delete vmContext[registrationCallbackName];
+ },
+ });
+
+ /**
+ * The context is configured. Now we need to load the files into it
+ * which should cause our registration callback to be invoked.
+ * We pass the filename here so we can get some nicer stack traces.
+ */
+ await Promise.all(
+ files.files.map(({content, url}) =>
+ this._runScript(vmContext, content, {filename: url}),
+ ),
+ );
+
+ /**
+ * With the files all loaded, we should have a registered callback.
+ * Let's assert that and then invoke the render process.
+ */
+ if (
+ typeof vmContext[registrationCallbackName][
+ registeredCbName
+ ] !== "function"
+ ) {
+ throw new KindError(
+ "No render callback was registered.",
+ Errors.Internal,
+ );
+ }
+
+ /**
+ * And now we run the registered callback inside the VM.
+ */
+ const result: RenderResult = await this._runScript(
+ vmContext,
+ `
+const cb = window["${registrationCallbackName}"]["${registeredCbName}"];
+cb();`,
+ );
+
+ /**
+ * Let's make sure that the rendered function returned something
+ * resembling a render result.
+ */
+ if (
+ result == null ||
+ !Object.prototype.hasOwnProperty.call(result, "body") ||
+ !Object.prototype.hasOwnProperty.call(result, "status") ||
+ !Object.prototype.hasOwnProperty.call(result, "headers")
+ ) {
+ throw new KindError(
+ `Malformed render result: ${JSON.stringify(result)}`,
+ Errors.Internal,
+ );
+ }
+
+ /**
+ * After all that, we should have a result, so let's return it and
+ * let our finally tidy up all the render context we built.
+ */
+ return result;
+ } finally {
+ /**
+ * We need to make sure that whatever happens, we tidy everything
+ * up.
+ */
+ await this._closeAll(closeables, renderAPI.logger);
+ }
+ };
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/jsdom-file-resource-loader.ts b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-file-resource-loader.ts
new file mode 100644
index 00000000..47c962a2
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-file-resource-loader.ts
@@ -0,0 +1,64 @@
+import fs from "fs";
+import path from "path";
+import {promisify} from "util";
+import {ResourceLoader} from "jsdom";
+import type {FetchOptions, AbortablePromise} from "jsdom";
+import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
+import {applyAbortablePromisesPatch} from "./apply-abortable-promises-patch";
+
+const readFileAsync = promisify(fs.readFile);
+
+/**
+ * A ResourceLoader implementation for JSDOM that loads files from disk.
+ */
+export class JSDOMFileResourceLoader extends ResourceLoader {
+ _rootFolder: string;
+
+ /**
+ * Create instance of the resource loader.
+ *
+ * @param {string} rootFolder
+ * The root of where we will load files.
+ */
+ constructor(rootFolder: string) {
+ // Patch before super to make sure promises get an abort.
+ applyAbortablePromisesPatch();
+
+ super();
+
+ if (!fs.existsSync(rootFolder)) {
+ throw new KindError("Root folder cannot be found", Errors.NotFound);
+ }
+
+ this._rootFolder = rootFolder;
+ }
+
+ _makeFilePath: (arg1: string) => string = (url) => {
+ /**
+ * If the url is a url, we are going to use it as a file path from root.
+ *
+ * If it is an absolute path, we just use it, otherwise we treat it
+ * as a relative path from root.
+ */
+ if (path.isAbsolute(url)) {
+ return url;
+ }
+
+ try {
+ const parsedURL = new URL(url);
+ return path.normalize(
+ path.join(this._rootFolder, parsedURL.pathname),
+ );
+ } catch (e: any) {
+ /* nothing */
+ }
+
+ // Assume relative path
+ return path.normalize(path.join(this._rootFolder, url));
+ };
+
+ fetch(url: string, options: FetchOptions): AbortablePromise | null {
+ const filePath = this._makeFilePath(url);
+ return readFileAsync(filePath) as AbortablePromise;
+ }
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/jsdom-resource-loader.ts b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-resource-loader.ts
new file mode 100644
index 00000000..b83f86b1
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/jsdom-resource-loader.ts
@@ -0,0 +1,228 @@
+import {URL} from "url";
+import type {Agent as HttpAgent} from "http";
+import type {Agent as HttpsAgent} from "https";
+import {ResourceLoader} from "jsdom";
+import type {FetchOptions} from "jsdom";
+import {getAgentForURL} from "@khanacademy/wonder-stuff-server";
+import {KindError, Errors} from "@khanacademy/wonder-stuff-core";
+import {Requests} from "@khanacademy/wonder-stuff-render-server";
+import type {
+ RequestOptions,
+ RenderAPI,
+ AbortablePromise,
+} from "@khanacademy/wonder-stuff-render-server";
+import {applyAbortablePromisesPatch} from "./apply-abortable-promises-patch";
+
+const noop = () => {
+ /* empty */
+};
+
+/**
+ * A ResourceLoader implementation for JSDOM that only allows for fetching JS
+ * files, and provides the ability to handle and modify the fetch return result.
+ *
+ * This can be useful for various things, such as intercepting script requests
+ * to execute them in a different manner than letting the DOM use a script tag.
+ * The return result could then be an empty string rather than the full script.
+ *
+ * The caller is responsible for maintaining script order based on call order.
+ *
+ * A JS file request is identified by the regular expression:
+ * /^.*\.js(?:\?.*)?/g
+ */
+export class JSDOMResourceLoader extends ResourceLoader {
+ /**
+ * Used to indicate if any pending requests are still needed so that we
+ * can report when an unused request is fulfilled.
+ */
+ _active: boolean;
+ _renderAPI: RenderAPI;
+ _requestOptions: RequestOptions;
+ _agents: {
+ [protocol: string]: HttpAgent | HttpsAgent;
+ };
+ _handleFetchResult:
+ | ((
+ result: Promise | void,
+ url: string,
+ options: FetchOptions,
+ ) => Promise | void)
+ | void;
+
+ static get EMPTY_RESPONSE(): AbortablePromise {
+ const response = Promise.resolve(
+ Buffer.from(""),
+ ) as AbortablePromise;
+ response.abort = noop;
+ return response;
+ }
+
+ /**
+ * Create instance of the resource loader.
+ *
+ * @param {RenderAPI} RenderAPI The render API that provides things like
+ * the logger.
+ * @param {RequestOptions} [requestOptions] Options that calibrate how
+ * requests are performed for this loader.
+ * @param {(result: ?Promise, url: string, options?: FetchOptions) => ?Promise}
+ * A callback that is invoked with the promise result. This can be used
+ * to ensure additional work is done on each request within the loader
+ * cycle, before the JSDOM call receives the result.
+ */
+ constructor(
+ renderAPI: RenderAPI,
+ requestOptions: RequestOptions = Requests.DefaultRequestOptions,
+ handleFetchResult?: (
+ result: Promise | void,
+ url: string,
+ options: FetchOptions,
+ ) => Promise | void,
+ ) {
+ // Patch before super to make sure promises get an abort.
+ applyAbortablePromisesPatch();
+
+ super();
+
+ if (renderAPI == null) {
+ throw new KindError("Must provide render API.", Errors.Internal);
+ }
+
+ this._active = true;
+ this._renderAPI = renderAPI;
+ this._requestOptions = requestOptions;
+ this._agents = {};
+ this._handleFetchResult = handleFetchResult;
+ }
+
+ _getAgent(url: string): HttpAgent | HttpsAgent {
+ const parsedURL = new URL(url);
+ const agent =
+ this._agents[parsedURL.protocol] || getAgentForURL(parsedURL);
+ this._agents[parsedURL.protocol] = agent;
+ return agent;
+ }
+
+ get isActive(): boolean {
+ return this._active;
+ }
+
+ close(): void {
+ this._active = false;
+
+ /**
+ * We need to destroy any agents we created or they may retain
+ * sockets that retain references to our JSDOM environment and cause
+ * a memory leak.
+ */
+ for (const key of Object.keys(this._agents)) {
+ this._agents[key].destroy();
+ delete this._agents[key];
+ }
+ }
+
+ fetch(url: string, options: FetchOptions): AbortablePromise | null {
+ const logger = this._renderAPI.logger;
+ const isInlineData = url.startsWith("data:");
+ const readableURLForLogging = isInlineData ? "inline data" : url;
+ if (!this._active) {
+ /**
+ * If we get here, then something is trying to fetch when our
+ * environment has closed us down. This could be in the reject
+ * or resolve of a promise, for example.
+ *
+ * If it's inlinedata, it really doesn't matter, so let's log it
+ * only if it's for a file.
+ */
+ if (!isInlineData) {
+ logger.warn(
+ `File fetch attempted after resource loader close: ${readableURLForLogging}`,
+ );
+ }
+
+ /**
+ * Though we intentionally don't want to load this file, we can't
+ * just return null per the spec as this can break promise
+ * resolutions that are relying on this file. Instead, we resolve
+ * as an empty string so things can tidy up properly.
+ */
+ return JSDOMResourceLoader.EMPTY_RESPONSE;
+ }
+
+ /**
+ * We must still be active.
+ * If this request is not a JavaScript file, we are going to return an
+ * empty response as we don't care about non-JS resources.
+ */
+ const JSFileRegex = /^.*\.js(?:\?.*)?/g;
+ if (!JSFileRegex.test(url)) {
+ logger.silly(`EMPTY: ${readableURLForLogging}`);
+
+ /**
+ * Though we intentionally don't want to load this file, we can't
+ * just return null per the spec as this can break promise
+ * resolutions that are relying on this file. Instead, we resolve
+ * as an empty string so things can tidy up properly.
+ */
+ return JSDOMResourceLoader.EMPTY_RESPONSE;
+ }
+
+ /**
+ * This must be a JavaScript file request. Let's make a request for the
+ * file and then handle it coming back.
+ */
+ const abortableFetch = Requests.request(logger, url, {
+ ...this._requestOptions,
+ agent: this._getAgent(url),
+ });
+ const handleInactive = abortableFetch.then((response) => {
+ const {aborted} = abortableFetch;
+ if (!this._active || aborted) {
+ if (!aborted) {
+ logger.info(
+ `File requested but never used: ${readableURLForLogging}`,
+ );
+ }
+
+ /**
+ * Just return an empty buffer so no code executes. The
+ * request function passed at construction will have handled
+ * caching of the real file request.
+ */
+ return Buffer.from("");
+ }
+
+ /**
+ * Our requests are always buffered.
+ *
+ * This is OK because we limit our requests to only text files.
+ * If this code were downloading binary data, this would not be
+ * helpful and we may want to consider using the default buffer
+ * setup that only buffers for things where a parser is available.
+ *
+ * Let's worry about that later.
+ */
+ return Buffer.from(response.text);
+ });
+
+ /**
+ * If we have a custom handler, we now let that do work.
+ */
+ const finalResult =
+ this._handleFetchResult == null
+ ? handleInactive
+ : this._handleFetchResult(handleInactive, url, options);
+
+ /**
+ * We have to turn this back into an abortable promise so that JSDOM
+ * can abort it when closing, if it needs to.
+ */
+ const abortableFinalResult: AbortablePromise =
+ finalResult as AbortablePromise;
+ abortableFinalResult.abort = abortableFetch.abort;
+ Object.defineProperty(abortableFinalResult, "aborted", {
+ get: () => abortableFetch.aborted,
+ });
+
+ return abortableFinalResult;
+ }
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/patch-against-dangling-timers.ts b/packages/wonder-stuff-render-environment-jsdom/src/patch-against-dangling-timers.ts
new file mode 100644
index 00000000..2e518490
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/patch-against-dangling-timers.ts
@@ -0,0 +1,87 @@
+import type {IGate, ITimerAPI} from "./types";
+
+/**
+ * Make a gate that can be open and closed.
+ * Default open.
+ */
+const makeGate = (): IGate => {
+ let gateOpen = true;
+ return {
+ open: () => {
+ gateOpen = true;
+ },
+ close: () => {
+ gateOpen = false;
+ },
+ get isOpen(): boolean {
+ return gateOpen;
+ },
+ };
+};
+
+/**
+ * Return a function that patches timeout functions with shared warning.
+ *
+ * This returns a call that, when used to patch setTimeout, setInterval, etc.
+ * will only warn once if any of the patched functions invokes a callback
+ * after the given gate is closed.
+ */
+const makeSingleWarningPatchFn = () => {
+ let warned = false;
+ return (obj: any, fnName: string, gate: IGate): void => {
+ const old = obj[fnName];
+ delete obj[fnName];
+ obj[fnName] = (callback: () => void, ...args: Array) => {
+ const gatedCallback = () => {
+ if (gate.isOpen) {
+ callback();
+ return;
+ }
+ if (!warned) {
+ warned = true;
+ /**
+ * This uses console because it runs in the VM, so it
+ * doesn't have direct access to our winston logging.
+ * Our virtual JSDOM console manages that.
+ */
+ // eslint-disable-next-line no-console
+ console.warn("Dangling timer(s) detected");
+ }
+ };
+ return old(gatedCallback, ...args);
+ };
+ };
+};
+
+/**
+ * Patch the timer API to protect against dangling timers.
+ *
+ * @returns {IGate} A gate API to control when timers should be allowed to run
+ * (gate is open), or when we should prevent them running and report dangling
+ * timers (gate is closed).
+ */
+export const patchAgainstDanglingTimers = (objToPatch: ITimerAPI): IGate => {
+ /**
+ * Make a gate so we can control how the timers are handled.
+ * The gate is default open.
+ */
+ const gate = makeGate();
+
+ /**
+ * Get a patch function with single warning.
+ * This ensures that each of the patched functions will only warn of
+ * dangling timers if none of the others have warned already.
+ * This keeps the log a little tidier and manageable.
+ */
+ const patchCallbackFnWithGate = makeSingleWarningPatchFn();
+
+ /**
+ * Patch the timer functions on window so that dangling timers don't kill
+ * us when we close the window.
+ */
+ patchCallbackFnWithGate(objToPatch, "setTimeout", gate);
+ patchCallbackFnWithGate(objToPatch, "setInterval", gate);
+ patchCallbackFnWithGate(objToPatch, "requestAnimationFrame", gate);
+
+ return gate;
+};
diff --git a/packages/wonder-stuff-render-environment-jsdom/src/types.ts b/packages/wonder-stuff-render-environment-jsdom/src/types.ts
new file mode 100644
index 00000000..46175696
--- /dev/null
+++ b/packages/wonder-stuff-render-environment-jsdom/src/types.ts
@@ -0,0 +1,106 @@
+import type {ResourceLoader} from "jsdom";
+import type {
+ RenderAPI,
+ ICloseable,
+} from "@khanacademy/wonder-stuff-render-server";
+
+/**
+ * Gate API for control flow.
+ */
+export interface IGate extends ICloseable {
+ /**
+ * Open the gate.
+ */
+ open(): void;
+ /**
+ * Close the gate.
+ */
+ close: () => Promise | void;
+ /**
+ * True, if the gate is open; otherwise, false.
+ */
+ get isOpen(): boolean;
+}
+
+/**
+ * Standard timer API as implemented by Node's global or a browser window.
+ */
+export interface ITimerAPI {
+ setTimeout: (typeof window)["setTimeout"];
+ setInterval: (typeof window)["setInterval"];
+ requestAnimationFrame: (typeof window)["requestAnimationFrame"];
+}
+
+/**
+ * A resource loader for use with JSDOM that can also have a close method for
+ * tidying up resources deterministically.
+ */
+export interface CloseableResourceLoader extends ResourceLoader, ICloseable {
+ /**
+ * Close the resource loader and tidy up resources.
+ *
+ * This is optional.
+ */
+ readonly close?: () => void;
+}
+
+/**
+ * Configuration for a JSDOM environment.
+ */
+export interface IJSDOMConfiguration {
+ /**
+ * The name of the callback function that should be exposed by the
+ * environment for renderable code to use when registering for rendering.
+ */
+ get registrationCallbackName(): string;
+ /**
+ * Get a JSDOM resource loader for the given render request.
+ *
+ * @param {string} url The URL that is to be rendered.
+ * @param {RenderAPI} renderAPI An API of utilities for assisting with the
+ * render operation.
+ * @returns {CloseableResourceLoader} A ResourceLoader instance for use
+ * with JSDOM that can optionally have a close() method, which will be
+ * invoked when the render completes.
+ */
+ getResourceLoader(
+ url: string,
+ renderAPI: RenderAPI,
+ ): CloseableResourceLoader;
+ /**
+ * Get the list of file URLs to retrieve and execute for the given request.
+ *
+ * @param {string} url The URL that is to be rendered.
+ * @param {RenderAPI} renderAPI An API of utilities for assisting with the
+ * render operation.
+ * @param {(url: string) => ?Promise} fetchFn
+ * Function to fetch a URL. Using this ensures proper tidy-up of associated
+ * sockets and agents.
+ * @returns {Promise>} An ordered array of absolute URLs for
+ * the JavaScript files that are to be executed. These are exectued in the
+ * same order as the array.
+ */
+ getFileList(
+ url: string,
+ renderAPI: RenderAPI,
+ fetchFn: (url: string) => Promise | null | undefined,
+ ): Promise>;
+ /**
+ * Perform any additional environment setup.
+ *
+ * This method gets access to the actual environment in which code will
+ * execute. Be careful what you do.
+ *
+ * @param {string} url The URL that is to be rendered.
+ * @param {RenderAPI} renderAPI An API of utilities for assisting with the
+ * render operation.
+ * @param {any} vmContext The actual environment that is being setup.
+ * @returns {?Promise} A promise that the additional setup is done.
+ */
+ afterEnvSetup(
+ url: string,
+ fileURLs: ReadonlyArray,
+ renderAPI: RenderAPI,
+ vmContext?: any,
+ ): Promise;
+}
diff --git a/packages/wonder-stuff-render-environment-jsdom/tsconfig.json b/packages/wonder-stuff-render-environment-jsdom/tsconfig.json
index 72c38350..39a3a35a 100644
--- a/packages/wonder-stuff-render-environment-jsdom/tsconfig.json
+++ b/packages/wonder-stuff-render-environment-jsdom/tsconfig.json
@@ -7,5 +7,9 @@
"outDir": "dist",
"rootDir": "src",
},
- "references": []
+ "references": [
+ {"path": "../wonder-stuff-core"},
+ {"path": "../wonder-stuff-server"},
+ {"path": "../wonder-stuff-render-server"},
+ ]
}
\ No newline at end of file
diff --git a/packages/wonder-stuff-render-server/src/types.ts b/packages/wonder-stuff-render-server/src/types.ts
index 2eaebbb0..0fbac019 100644
--- a/packages/wonder-stuff-render-server/src/types.ts
+++ b/packages/wonder-stuff-render-server/src/types.ts
@@ -28,7 +28,7 @@ export interface ICloseable {
/**
* Close the closeable.
*/
- readonly close?: () => Promise | null | undefined;
+ readonly close?: () => Promise | void;
}
export type ResponseSource = "unknown" | "cache" | "new request";
diff --git a/tsconfig-build.json b/tsconfig-build.json
index c6b5c811..6b7ee573 100644
--- a/tsconfig-build.json
+++ b/tsconfig-build.json
@@ -7,6 +7,8 @@
"references": [
{"path": "./packages/wonder-stuff-core"},
{"path": "./packages/wonder-stuff-i18n"},
+ {"path": "./packages/wonder-stuff-render-environment-jsdom"},
+ {"path": "./packages/wonder-stuff-render-server"},
{"path": "./packages/wonder-stuff-sentry"},
{"path": "./packages/wonder-stuff-server"},
{"path": "./packages/wonder-stuff-testing"},
diff --git a/yarn.lock b/yarn.lock
index 101dd1d4..1797dd23 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2220,6 +2220,15 @@
expect "^29.0.0"
pretty-format "^29.0.0"
+"@types/jsdom@^21.1.1":
+ version "21.1.1"
+ resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-21.1.1.tgz#e59e26352071267b507bf04d51841a1d7d3e8617"
+ integrity sha512-cZFuoVLtzKP3gmq9eNosUL1R50U+USkbLtUQ1bYVgl/lKp0FZM7Cq4aIHAL8oIvQ17uSHi7jXPtfDOdjPwBE7A==
+ dependencies:
+ "@types/node" "*"
+ "@types/tough-cookie" "*"
+ parse5 "^7.0.0"
+
"@types/json-schema@^7.0.9":
version "7.0.11"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
@@ -2342,6 +2351,11 @@
"@types/cookiejar" "*"
"@types/node" "*"
+"@types/tough-cookie@*":
+ version "4.0.2"
+ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.2.tgz#6286b4c7228d58ab7866d19716f3696e03a09397"
+ integrity sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==
+
"@types/triple-beam@^1.3.2":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.2.tgz#38ecb64f01aa0d02b7c8f4222d7c38af6316fef8"
@@ -2450,6 +2464,11 @@
"@typescript-eslint/types" "5.57.0"
eslint-visitor-keys "^3.3.0"
+abab@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
+ integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
+
abbrev@1, abbrev@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
@@ -2470,12 +2489,25 @@ accepts@~1.3.8:
mime-types "~2.1.34"
negotiator "0.6.3"
+acorn-globals@^7.0.0:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
+ integrity sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==
+ dependencies:
+ acorn "^8.1.0"
+ acorn-walk "^8.0.2"
+
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
-acorn@^8.5.0, acorn@^8.8.0:
+acorn-walk@^8.0.2:
+ version "8.2.0"
+ resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
+ integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
+
+acorn@^8.1.0, acorn@^8.5.0, acorn@^8.8.0, acorn@^8.8.2:
version "8.8.2"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
@@ -3335,6 +3367,13 @@ cross-spawn@^7.0.2, cross-spawn@^7.0.3:
shebang-command "^2.0.0"
which "^2.0.1"
+cssstyle@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-3.0.0.tgz#17ca9c87d26eac764bb8cfd00583cff21ce0277a"
+ integrity sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==
+ dependencies:
+ rrweb-cssom "^0.6.0"
+
csv-generate@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/csv-generate/-/csv-generate-3.4.3.tgz#bc42d943b45aea52afa896874291da4b9108ffff"
@@ -3365,6 +3404,15 @@ damerau-levenshtein@^1.0.8:
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==
+data-urls@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-4.0.0.tgz#333a454eca6f9a5b7b0f1013ff89074c3f522dd4"
+ integrity sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==
+ dependencies:
+ abab "^2.0.6"
+ whatwg-mimetype "^3.0.0"
+ whatwg-url "^12.0.0"
+
debug@2.6.9:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@@ -3399,6 +3447,11 @@ decamelize@^1.1.0, decamelize@^1.2.0:
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==
+decimal.js@^10.4.3:
+ version "10.4.3"
+ resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23"
+ integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==
+
dedent@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
@@ -3530,6 +3583,13 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
+domexception@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/domexception/-/domexception-4.0.0.tgz#4ad1be56ccadc86fc76d033353999a8037d03673"
+ integrity sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==
+ dependencies:
+ webidl-conversions "^7.0.0"
+
dot-prop@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-6.0.1.tgz#fc26b3cf142b9e59b74dbd39ed66ce620c681083"
@@ -3640,6 +3700,11 @@ ent@^2.2.0:
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
+entities@^4.4.0:
+ version "4.4.0"
+ resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174"
+ integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==
+
entities@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
@@ -3779,6 +3844,18 @@ escodegen@^1.13.0:
optionalDependencies:
source-map "~0.6.1"
+escodegen@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.0.0.tgz#5e32b12833e8aa8fa35e1bf0befa89380484c7dd"
+ integrity sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==
+ dependencies:
+ esprima "^4.0.1"
+ estraverse "^5.2.0"
+ esutils "^2.0.2"
+ optionator "^0.8.1"
+ optionalDependencies:
+ source-map "~0.6.1"
+
eslint-config-prettier@^8.8.0:
version "8.8.0"
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.8.0.tgz#bfda738d412adc917fd7b038857110efe98c9348"
@@ -4339,6 +4416,15 @@ form-data@^3.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
+form-data@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+ integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
+ dependencies:
+ asynckit "^0.4.0"
+ combined-stream "^1.0.8"
+ mime-types "^2.1.12"
+
formidable@^1.2.2:
version "1.2.6"
resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168"
@@ -4846,6 +4932,13 @@ hosted-git-info@^6.0.0:
dependencies:
lru-cache "^7.5.1"
+html-encoding-sniffer@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9"
+ integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==
+ dependencies:
+ whatwg-encoding "^2.0.0"
+
html-escaper@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
@@ -4876,7 +4969,7 @@ http-proxy-agent@^5.0.0:
agent-base "6"
debug "4"
-https-proxy-agent@^5.0.0:
+https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
@@ -4908,7 +5001,7 @@ iconv-lite@0.4.24, iconv-lite@^0.4.24:
dependencies:
safer-buffer ">= 2.1.2 < 3"
-iconv-lite@^0.6.2:
+iconv-lite@0.6.3, iconv-lite@^0.6.2:
version "0.6.3"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
@@ -5159,6 +5252,11 @@ is-plain-obj@^3.0.0:
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7"
integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==
+is-potential-custom-element-name@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
+ integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
+
is-regex@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
@@ -5742,6 +5840,38 @@ jsdoc@^4.0.0:
strip-json-comments "^3.1.0"
underscore "~1.13.2"
+jsdom@^21.1.1:
+ version "21.1.1"
+ resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-21.1.1.tgz#ab796361e3f6c01bcfaeda1fea3c06197ac9d8ae"
+ integrity sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==
+ dependencies:
+ abab "^2.0.6"
+ acorn "^8.8.2"
+ acorn-globals "^7.0.0"
+ cssstyle "^3.0.0"
+ data-urls "^4.0.0"
+ decimal.js "^10.4.3"
+ domexception "^4.0.0"
+ escodegen "^2.0.0"
+ form-data "^4.0.0"
+ html-encoding-sniffer "^3.0.0"
+ http-proxy-agent "^5.0.0"
+ https-proxy-agent "^5.0.1"
+ is-potential-custom-element-name "^1.0.1"
+ nwsapi "^2.2.2"
+ parse5 "^7.1.2"
+ rrweb-cssom "^0.6.0"
+ saxes "^6.0.0"
+ symbol-tree "^3.2.4"
+ tough-cookie "^4.1.2"
+ w3c-xmlserializer "^4.0.0"
+ webidl-conversions "^7.0.0"
+ whatwg-encoding "^2.0.0"
+ whatwg-mimetype "^3.0.0"
+ whatwg-url "^12.0.1"
+ ws "^8.13.0"
+ xml-name-validator "^4.0.0"
+
jsesc@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
@@ -6609,6 +6739,11 @@ npmlog@^6.0.0:
gauge "^4.0.3"
set-blocking "^2.0.0"
+nwsapi@^2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0"
+ integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==
+
object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -6869,6 +7004,13 @@ parse-package-name@^0.1.0:
resolved "https://registry.yarnpkg.com/parse-package-name/-/parse-package-name-0.1.0.tgz#3f44dd838feb4c2be4bf318bae4477d7706bade4"
integrity sha512-OT2+32knn014ggXMpGjZeHHsTYwOvHmRAMFtVBZstWAnR4UVIOw+JOhWZUCv5JwZQAMiisfdF2K5SyGI5OXXIg==
+parse5@^7.0.0, parse5@^7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32"
+ integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==
+ dependencies:
+ entities "^4.4.0"
+
parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -7144,6 +7286,11 @@ pseudomap@^1.0.2:
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==
+psl@^1.1.33:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7"
+ integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==
+
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
@@ -7161,7 +7308,7 @@ pumpify@^2.0.1:
inherits "^2.0.3"
pump "^3.0.0"
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"
integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==
@@ -7185,6 +7332,11 @@ qs@^6.9.4:
dependencies:
side-channel "^1.0.4"
+querystringify@^2.1.1:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
+ integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
+
queue-microtask@^1.2.2:
version "1.2.3"
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
@@ -7378,6 +7530,11 @@ require-main-filename@^2.0.0:
resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
+requires-port@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+ integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
+
requizzle@^0.2.3:
version "0.2.4"
resolved "https://registry.yarnpkg.com/requizzle/-/requizzle-0.2.4.tgz#319eb658b28c370f0c20f968fa8ceab98c13d27c"
@@ -7501,6 +7658,11 @@ rollup@^2.79.1:
optionalDependencies:
fsevents "~2.3.2"
+rrweb-cssom@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1"
+ integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==
+
run-parallel@^1.1.9:
version "1.2.0"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
@@ -7537,6 +7699,13 @@ safe-stable-stringify@^2.3.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+saxes@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5"
+ integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
+ dependencies:
+ xmlchars "^2.2.0"
+
"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
@@ -8008,6 +8177,11 @@ supports-preserve-symlinks-flag@^1.0.0:
resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+symbol-tree@^3.2.4:
+ version "3.2.4"
+ resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
+ integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
+
synckit@^0.8.5:
version "0.8.5"
resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.5.tgz#b7f4358f9bb559437f9f167eb6bc46b3c9818fa3"
@@ -8127,6 +8301,23 @@ toidentifier@1.0.1:
resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+tough-cookie@^4.1.2:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874"
+ integrity sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==
+ dependencies:
+ psl "^1.1.33"
+ punycode "^2.1.1"
+ universalify "^0.2.0"
+ url-parse "^1.5.3"
+
+tr46@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-4.1.1.tgz#281a758dcc82aeb4fe38c7dfe4d11a395aac8469"
+ integrity sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==
+ dependencies:
+ punycode "^2.3.0"
+
tr46@~0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
@@ -8357,6 +8548,11 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+universalify@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0"
+ integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==
+
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -8377,6 +8573,14 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
+url-parse@^1.5.3:
+ version "1.5.10"
+ resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"
+ integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==
+ dependencies:
+ querystringify "^2.1.1"
+ requires-port "^1.0.0"
+
util-deprecate@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -8421,6 +8625,13 @@ vary@~1.1.2:
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+w3c-xmlserializer@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073"
+ integrity sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==
+ dependencies:
+ xml-name-validator "^4.0.0"
+
walker@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f"
@@ -8440,6 +8651,31 @@ webidl-conversions@^3.0.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+webidl-conversions@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
+ integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
+
+whatwg-encoding@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53"
+ integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==
+ dependencies:
+ iconv-lite "0.6.3"
+
+whatwg-mimetype@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7"
+ integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==
+
+whatwg-url@^12.0.0, whatwg-url@^12.0.1:
+ version "12.0.1"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-12.0.1.tgz#fd7bcc71192e7c3a2a97b9a8d6b094853ed8773c"
+ integrity sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==
+ dependencies:
+ tr46 "^4.1.1"
+ webidl-conversions "^7.0.0"
+
whatwg-url@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@@ -8591,6 +8827,21 @@ write-file-atomic@^4.0.2:
imurmurhash "^0.1.4"
signal-exit "^3.0.7"
+ws@^8.13.0:
+ version "8.13.0"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0"
+ integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==
+
+xml-name-validator@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
+ integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
+
+xmlchars@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
+ integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
+
xmlcreate@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/xmlcreate/-/xmlcreate-2.0.4.tgz#0c5ab0f99cdd02a81065fa9cd8f8ae87624889be"