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"