From 579e6187cdea9bdd9d9596006b58db7d97ffa3ce Mon Sep 17 00:00:00 2001 From: Jeff Yates Date: Fri, 31 Mar 2023 17:01:44 -0500 Subject: [PATCH] [fei4960.2.jsdom] Migrate JSDOM render environment (#607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary: This migrates the JSDOM render environment from Khan/render-gateway. Now it is its own package as it should've been. This also tweaks the rollup config to cope with dynamic imports which was needed for the new package to build. The only migration piece left is to bring in the example server implementations for testing various paths through the code. Issue: FEI-4960 ## Test plan: `yarn test` `yarn build` `yarn typecheck` Author: somewhatabstract Reviewers: kevinbarabash, somewhatabstract, github-code-scanning[bot] Required Reviewers: Approved By: kevinbarabash Checks: ⌛ Test (macos-latest, 16.x), ✅ codecov/project, ✅ CodeQL, ✅ Lint, typecheck, and coverage check (ubuntu-latest, 16.x), ✅ Prime node_modules cache for primary configuration (ubuntu-latest, 16.x), ✅ gerald, ✅ Analyze (javascript), ⏭ dependabot Pull Request URL: https://github.com/Khan/wonder-stuff/pull/607 --- .changeset/healthy-ladybugs-travel.md | 5 + .changeset/serious-frogs-lay.md | 5 + build-settings/rollup.config.js | 25 +- package.json | 2 + .../package.json | 9 +- .../__tests__/__data__/file-loader-test.txt | 1 + .../jsdom-configuration.test.ts.snap | 9 + .../jsdom-environment.test.ts.snap | 13 + .../apply-abortable-promises-patch.test.ts | 61 + .../closeable-virtual-console.test.ts | 124 ++ .../src/__tests__/index.test.ts | 17 + .../src/__tests__/jsdom-configuration.test.ts | 143 ++ .../src/__tests__/jsdom-environment.test.ts | 1261 +++++++++++++++++ .../jsdom-file-resource-loader.test.ts | 68 + .../__tests__/jsdom-resource-loader.test.ts | 515 +++++++ .../patch-against-dangling-timers.test.ts | 481 +++++++ .../src/apply-abortable-promises-patch.ts | 47 + .../src/closeable-virtual-console.ts | 71 + .../src/index.ts | 6 + .../src/jsdom-configuration.ts | 94 ++ .../src/jsdom-environment.ts | 380 +++++ .../src/jsdom-file-resource-loader.ts | 64 + .../src/jsdom-resource-loader.ts | 228 +++ .../src/patch-against-dangling-timers.ts | 87 ++ .../src/types.ts | 106 ++ .../tsconfig.json | 6 +- .../wonder-stuff-render-server/src/types.ts | 2 +- tsconfig-build.json | 2 + yarn.lock | 259 +++- 29 files changed, 4078 insertions(+), 13 deletions(-) create mode 100644 .changeset/healthy-ladybugs-travel.md create mode 100644 .changeset/serious-frogs-lay.md create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/__data__/file-loader-test.txt create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-configuration.test.ts.snap create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/__snapshots__/jsdom-environment.test.ts.snap create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/apply-abortable-promises-patch.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/closeable-virtual-console.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/index.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-configuration.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-environment.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-file-resource-loader.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/jsdom-resource-loader.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/__tests__/patch-against-dangling-timers.test.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/apply-abortable-promises-patch.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/closeable-virtual-console.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/jsdom-configuration.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/jsdom-environment.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/jsdom-file-resource-loader.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/jsdom-resource-loader.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/patch-against-dangling-timers.ts create mode 100644 packages/wonder-stuff-render-environment-jsdom/src/types.ts 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"