UNPKG

@googlemaps/js-api-loader

Version:

Wrapper for the loading of Google Maps JavaScript API script in the browser

470 lines (387 loc) 15.5 kB
/** * Copyright 2019 Google LLC. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at. * * Http://www.apache.org/licenses/LICENSE-2.0. * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* eslint @typescript-eslint/no-explicit-any: 0 */ import { DEFAULT_ID, Loader, LoaderOptions, LoaderStatus } from "."; jest.useFakeTimers(); afterEach(() => { document.getElementsByTagName("html")[0].innerHTML = ""; delete Loader["instance"]; if (window.google) delete window.google; }); test.each([ [ {}, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async", ], [ { apiKey: "foo" }, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async&key=foo", ], [ { apiKey: "foo", version: "weekly", libraries: ["marker", "places"], language: "language", region: "region", }, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async&key=foo&libraries=marker,places&language=language&region=region&v=weekly", ], [ { mapIds: ["foo", "bar"] }, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async&map_ids=foo,bar", ], [ { url: "https://example.com/js" }, "https://example.com/js?callback=__googleMapsCallback&loading=async", ], [ { client: "bar", channel: "foo" }, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async&channel=foo&client=bar", ], [ { authReferrerPolicy: "origin" }, "https://maps.googleapis.com/maps/api/js?callback=__googleMapsCallback&loading=async&auth_referrer_policy=origin", ], ])("createUrl is correct", (options: LoaderOptions, expected: string) => { const loader = new Loader(options); expect(loader.createUrl()).toEqual(expected); expect(loader.status).toBe(LoaderStatus.INITIALIZED); }); test("uses default id if empty string", () => { expect(new Loader({ apiKey: "foo", id: "" }).id).toBe(DEFAULT_ID); }); test("setScript adds a script to head with correct attributes", async () => { const loader = new Loader({ apiKey: "foo" }); loader["setScript"](); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.id).toEqual(loader.id); }); test("setScript adds a script with id", async () => { const loader = new Loader({ apiKey: "foo", id: "bar" }); loader["setScript"](); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.localName).toEqual("script"); expect(loader.id).toEqual("bar"); expect(script.id).toEqual("bar"); }); test("setScript does not add second script with same id", async () => { new Loader({ apiKey: "foo", id: "bar" })["setScript"](); new Loader({ apiKey: "foo", id: "bar" })["setScript"](); await 0; new Loader({ apiKey: "foo", id: "bar" })["setScript"](); await 0; expect(document.head.childNodes.length).toBe(1); }); test("setScript adds a script to head with valid src", async () => { const loader = new Loader({ apiKey: "foo" }); loader["setScript"](); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.src).toEqual( "https://maps.googleapis.com/maps/api/js?libraries=core&key=foo&callback=google.maps.__ib__" ); }); test("setScript adds a script to head with valid src with libraries", async () => { const loader = new Loader({ apiKey: "foo", libraries: ["marker", "places"] }); loader["setScript"](); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.src).toEqual( "https://maps.googleapis.com/maps/api/js?libraries=marker%2Cplaces&key=foo&callback=google.maps.__ib__" ); }); test("load should return a promise that resolves even if called twice", () => { const loader = new Loader({ apiKey: "foo" }); loader.importLibrary = (() => Promise.resolve()) as any; expect.assertions(1); const promise = Promise.all([loader.load(), loader.load()]).then(() => { expect(loader["done"]).toBeTruthy(); }); return promise; }); test("loadCallback callback should fire", () => { const loader = new Loader({ apiKey: "foo" }); loader.importLibrary = (() => Promise.resolve()) as any; expect.assertions(2); loader.loadCallback((e: Event) => { expect(loader["done"]).toBeTruthy(); expect(e).toBeUndefined(); }); }); test("script onerror should reject promise", async () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); const rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); loader["loadErrorCallback"]( new ErrorEvent("ErrorEvent(", { error: new Error("") }) ); await rejection; expect(loader["done"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); expect(loader["errors"].length).toBe(1); expect(loader.status).toBe(LoaderStatus.FAILURE); }); test("script onerror should reject promise with multiple loaders", async () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); const extraLoader = new Loader({ apiKey: "foo", retries: 0 }); let rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); loader["loadErrorCallback"]( new ErrorEvent("ErrorEvent(", { error: new Error("") }) ); await rejection; expect(loader["done"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); expect(loader["onerrorEvent"]).toBeInstanceOf(ErrorEvent); rejection = expect(extraLoader.load()).rejects.toBeInstanceOf(Error); loader["loadErrorCallback"]( new ErrorEvent("ErrorEvent(", { error: new Error("") }) ); await rejection; expect(extraLoader["done"]).toBeTruthy(); expect(extraLoader["loading"]).toBeFalsy(); }); test("script onerror should retry", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; const rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); // eslint-disable-next-line @typescript-eslint/no-empty-function console.error = jest.fn(); // wait for the first failure await 0; await 0; expect(loader["errors"].length).toBe(1); // trigger the retry delay: jest.runAllTimers(); await rejection; expect(loader["errors"].length).toBe(2); expect(loader["done"]).toBeTruthy(); expect(loader["failed"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); expect(deleteScript).toHaveBeenCalledTimes(1); expect(console.error).toHaveBeenCalledTimes(loader.retries); }); test("script onerror should reset retry mechanism with next loader", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; // eslint-disable-next-line @typescript-eslint/no-empty-function console.error = jest.fn(); let rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); // wait for the first first failure await 0; await 0; expect(loader["errors"].length).toBe(1); // trigger the retry delay: jest.runAllTimers(); await rejection; // try again... rejection = expect(loader.load()).rejects.toBeInstanceOf(Error); expect(loader["done"]).toBeFalsy(); expect(loader["failed"]).toBeFalsy(); expect(loader["loading"]).toBeTruthy(); expect(loader["errors"].length).toBe(0); // wait for the second first failure await 0; await 0; expect(loader["errors"].length).toBe(1); // trigger the retry delay: jest.runAllTimers(); await rejection; expect(deleteScript).toHaveBeenCalledTimes(3); expect(console.error).toHaveBeenCalledTimes(loader.retries * 2); }); test("script onerror should not reset retry mechanism with parallel loaders", async () => { const loader = new Loader({ apiKey: "foo", retries: 1 }); const deleteScript = jest.spyOn(loader, "deleteScript"); loader.importLibrary = (() => Promise.reject(new Error("fake error"))) as any; // eslint-disable-next-line @typescript-eslint/no-empty-function console.error = jest.fn(); const rejection1 = expect(loader.load()).rejects.toBeInstanceOf(Error); const rejection2 = expect(loader.load()).rejects.toBeInstanceOf(Error); // wait for the first first failure await 0; await 0; jest.runAllTimers(); await Promise.all([rejection1, rejection2]); expect(loader["done"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); expect(loader["errors"].length).toBe(2); expect(deleteScript).toHaveBeenCalledTimes(1); expect(console.error).toHaveBeenCalledTimes(loader.retries); }); test("reset should clear state", () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); const deleteScript = jest.spyOn(loader, "deleteScript"); loader["done"] = true; loader["loading"] = false; loader["errors"] = [new ErrorEvent("foo")]; loader["reset"](); expect(loader["done"]).toBeFalsy(); expect(loader["loading"]).toBeFalsy(); expect(loader["onerrorEvent"]).toBe(null); expect(deleteScript).toHaveBeenCalledTimes(1); }); test("failed getter should be correct", () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); // initial expect(loader["failed"]).toBeFalsy(); // not done loader["done"] = false; loader["loading"] = false; loader["errors"] = [new ErrorEvent("foo")]; expect(loader["failed"]).toBeFalsy(); // still loading loader["done"] = false; loader["loading"] = true; loader["errors"] = [new ErrorEvent("foo")]; expect(loader["failed"]).toBeFalsy(); // no errors loader["done"] = true; loader["loading"] = false; loader["errors"] = []; expect(loader["failed"]).toBeFalsy(); // done with errors loader["done"] = true; loader["loading"] = false; loader["errors"] = [new ErrorEvent("foo")]; expect(loader["failed"]).toBeTruthy(); }); test("loader should not reset retry mechanism if successfully loaded", async () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); const deleteScript = jest.spyOn(loader, "deleteScript"); loader.importLibrary = (() => Promise.resolve()) as any; await expect(loader.load()).resolves.not.toBeUndefined(); expect(loader["done"]).toBeTruthy(); expect(loader["loading"]).toBeFalsy(); expect(deleteScript).toHaveBeenCalledTimes(0); }); test("singleton should be used", () => { const loader = new Loader({ apiKey: "foo" }); const extraLoader = new Loader({ apiKey: "foo" }); expect(extraLoader).toBe(loader); loader["done"] = true; expect(extraLoader["done"]).toBe(loader["done"]); expect(loader.status).toBe(LoaderStatus.SUCCESS); }); test("singleton should throw with different options", () => { new Loader({ apiKey: "foo" }); expect(() => { new Loader({ apiKey: "bar" }); }).toThrow(); }); test("loader should resolve immediately when successfully loaded", async () => { // use await/async pattern since the promise resolves without trigger const loader = new Loader({ apiKey: "foo", retries: 0 }); loader["done"] = true; // TODO causes warning window.google = { maps: { version: "3.*.*" } as any }; await expect(loader.loadPromise()).resolves.toBeDefined(); }); test("loader should resolve immediately when failed loading", async () => { // use await/async pattern since the promise rejects without trigger const loader = new Loader({ apiKey: "foo", retries: 0 }); loader["done"] = true; loader["onerrorEvent"] = new ErrorEvent("ErrorEvent(", { error: new Error(""), }); await expect(loader.loadPromise()).rejects.toBeDefined(); }); test("loader should wait if already loading", () => { const loader = new Loader({ apiKey: "foo", retries: 0 }); loader["loading"] = true; expect(loader.status).toBe(LoaderStatus.LOADING); loader.load(); }); test("setScript adds a nonce", async () => { const nonce = "bar"; const loader = new Loader({ apiKey: "foo", nonce }); loader["setScript"](); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.nonce).toBe(nonce); }); test("loader should resolve immediately when google.maps defined", async () => { const loader = new Loader({ apiKey: "foo" }); window.google = { maps: { version: "3.*.*" } as any }; console.warn = jest.fn(); await expect(loader.loadPromise()).resolves.toBeDefined(); delete window.google; expect(console.warn).toHaveBeenCalledTimes(1); }); test("loader should not warn if done and google.maps is defined", async () => { const loader = new Loader({ apiKey: "foo" }); loader["done"] = true; window.google = { maps: { version: "3.*.*" } as any }; console.warn = jest.fn(); await expect(loader.loadPromise()).resolves.toBeDefined(); delete window.google; expect(console.warn).toHaveBeenCalledTimes(0); }); test("deleteScript removes script tag from head", async () => { const loader = new Loader({ apiKey: "foo" }); loader["setScript"](); await 0; expect(document.head.childNodes.length).toBe(1); loader.deleteScript(); expect(document.head.childNodes.length).toBe(0); // should work without script existing loader.deleteScript(); expect(document.head.childNodes.length).toBe(0); }); test("importLibrary resolves correctly", async () => { window.google = { maps: {} } as any; google.maps.importLibrary = async (name) => ({ [name]: "fake" }) as any; const loader = new Loader({ apiKey: "foo" }); const corePromise = loader.importLibrary("core"); const core = await corePromise; expect(core).toEqual({ core: "fake" }); }); test("importLibrary resolves correctly without warning with sequential await", async () => { console.warn = jest.fn(); window.google = { maps: {} } as any; google.maps.importLibrary = async (name) => { google.maps.version = "3.*.*"; return { [name]: "fake" } as any; }; const loader = new Loader({ apiKey: "foo" }); const core = await loader.importLibrary("core"); const marker = await loader.importLibrary("marker"); expect(console.warn).toHaveBeenCalledTimes(0); expect(core).toEqual({ core: "fake" }); expect(marker).toEqual({ marker: "fake" }); }); test("importLibrary can also set up bootstrap libraries (if bootstrap libraries empty)", async () => { const loader = new Loader({ apiKey: "foo" }); loader.importLibrary("marker"); loader.importLibrary("places"); await 0; const script = document.head.childNodes[0] as HTMLScriptElement; expect(script.src).toEqual( "https://maps.googleapis.com/maps/api/js?libraries=core%2Cmarker%2Cplaces&key=foo&callback=google.maps.__ib__" ); }); test("importLibrary resolves correctly even with different bootstrap libraries", async () => { window.google = { maps: {} } as any; google.maps.importLibrary = async (name) => ({ [name]: "fake" }) as any; const loader = new Loader({ apiKey: "foo", libraries: ["places"] }); const corePromise = loader.importLibrary("core"); const core = await corePromise; expect(core).toEqual({ core: "fake" }); expect(await loader.importLibrary("places")).toEqual({ places: "fake" }); });