diff --git a/ci/dev/lint.sh b/ci/dev/lint.sh index c4875ff1..9ba576e0 100755 --- a/ci/dev/lint.sh +++ b/ci/dev/lint.sh @@ -6,7 +6,7 @@ main() { eslint --max-warnings=0 --fix $(git ls-files "*.ts" "*.tsx" "*.js" | grep -v "lib/vscode") stylelint $(git ls-files "*.css" | grep -v "lib/vscode") - tsc --noEmit + tsc --noEmit --skipLibCheck shellcheck -e SC2046,SC2164,SC2154,SC1091,SC1090,SC2002 $(git ls-files "*.sh" | grep -v "lib/vscode") if command -v helm && helm kubeval --help > /dev/null; then helm kubeval ci/helm-chart diff --git a/package.json b/package.json index 7f427a78..af412f5c 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,9 @@ "/release-npm-package", "/release-gcp", "/release-images" - ] + ], + "moduleNameMapper": { + "^.+\\.(css|less)$": "/test/cssStub.ts" + } } } diff --git a/src/browser/register.ts b/src/browser/register.ts index 4f834580..1079d159 100644 --- a/src/browser/register.ts +++ b/src/browser/register.ts @@ -1,18 +1,24 @@ -import { getOptions, normalize } from "../common/util" - -const options = getOptions() +import { getOptions, normalize, logError } from "../common/util" import "./pages/error.css" import "./pages/global.css" import "./pages/login.css" -if ("serviceWorker" in navigator) { +async function registerServiceWorker(): Promise { + const options = getOptions() const path = normalize(`${options.csStaticBase}/dist/serviceWorker.js`) - navigator.serviceWorker - .register(path, { + try { + await navigator.serviceWorker.register(path, { scope: (options.base ?? "") + "/", }) - .then(() => { - console.log("[Service Worker] registered") - }) + console.log("[Service Worker] registered") + } catch (error) { + logError(`[Service Worker] registration`, error) + } +} + +if (typeof navigator !== "undefined" && "serviceWorker" in navigator) { + registerServiceWorker() +} else { + console.error(`[Service Worker] navigator is undefined`) } diff --git a/src/browser/serviceWorker.ts b/src/browser/serviceWorker.ts index 1bee59bf..25765a1a 100644 --- a/src/browser/serviceWorker.ts +++ b/src/browser/serviceWorker.ts @@ -1,11 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ self.addEventListener("install", () => { - console.log("[Service Worker] install") + console.log("[Service Worker] installed") }) self.addEventListener("activate", (event: any) => { event.waitUntil((self as any).clients.claim()) + console.log("[Service Worker] activated") }) self.addEventListener("fetch", () => { diff --git a/test/constants.test.ts b/test/constants.test.ts index 457f57fa..0cb33f2f 100644 --- a/test/constants.test.ts +++ b/test/constants.test.ts @@ -1,16 +1,11 @@ -// Note: we need to import logger from the root -// because this is the logger used in logError in ../src/common/util -import { logger } from "../node_modules/@coder/logger" import { commit, getPackageJson, version } from "../src/node/constants" +import { loggerModule } from "./helpers" + +// jest.mock is hoisted above the imports so we must use `require` here. +jest.mock("@coder/logger", () => require("./helpers").loggerModule) describe("constants", () => { describe("getPackageJson", () => { - let spy: jest.SpyInstance - - beforeEach(() => { - spy = jest.spyOn(logger, "warn") - }) - afterEach(() => { jest.clearAllMocks() }) @@ -24,8 +19,8 @@ describe("constants", () => { getPackageJson("./package.json") - expect(spy).toHaveBeenCalled() - expect(spy).toHaveBeenCalledWith(expectedErrorMessage) + expect(loggerModule.logger.warn).toHaveBeenCalled() + expect(loggerModule.logger.warn).toHaveBeenCalledWith(expectedErrorMessage) }) it("should find the package.json", () => { diff --git a/test/cssStub.ts b/test/cssStub.ts new file mode 100644 index 00000000..06aeb475 --- /dev/null +++ b/test/cssStub.ts @@ -0,0 +1,5 @@ +// Note: this is needed for the register.test.ts +// This is because inside src/browser/register.ts +// we import CSS files, which Jest can't handle unless we tell it how to +// See: https://stackoverflow.com/a/39434579/3015595 +module.exports = {} diff --git a/test/emitter.test.ts b/test/emitter.test.ts index 8ff5106a..3ea72ae2 100644 --- a/test/emitter.test.ts +++ b/test/emitter.test.ts @@ -1,9 +1,10 @@ // Note: we need to import logger from the root // because this is the logger used in logError in ../src/common/util import { logger } from "../node_modules/@coder/logger" + import { Emitter } from "../src/common/emitter" -describe("Emitter", () => { +describe("emitter", () => { let spy: jest.SpyInstance beforeEach(() => { diff --git a/test/helpers.ts b/test/helpers.ts index 193fd0ca..2aeb82df 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -33,3 +33,15 @@ export function createCookieIfDoesntExist(cookies: Array, cookieToStore: } return cookies } + +export const loggerModule = { + field: jest.fn(), + level: 2, + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }, +} diff --git a/test/register.test.ts b/test/register.test.ts new file mode 100644 index 00000000..a80c2034 --- /dev/null +++ b/test/register.test.ts @@ -0,0 +1,87 @@ +import { JSDOM } from "jsdom" +import { loggerModule } from "./helpers" + +describe("register", () => { + describe("when navigator and serviceWorker are defined", () => { + const mockRegisterFn = jest.fn() + + beforeAll(() => { + const { window } = new JSDOM() + global.window = (window as unknown) as Window & typeof globalThis + global.document = window.document + global.navigator = window.navigator + global.location = window.location + + Object.defineProperty(global.navigator, "serviceWorker", { + value: { + register: mockRegisterFn, + }, + }) + }) + + beforeEach(() => { + jest.mock("@coder/logger", () => loggerModule) + }) + + afterEach(() => { + mockRegisterFn.mockClear() + jest.resetModules() + }) + + afterAll(() => { + jest.restoreAllMocks() + + // We don't want these to stay around because it can affect other tests + global.window = (undefined as unknown) as Window & typeof globalThis + global.document = (undefined as unknown) as Document & typeof globalThis + global.navigator = (undefined as unknown) as Navigator & typeof globalThis + global.location = (undefined as unknown) as Location & typeof globalThis + }) + + it("should register a ServiceWorker", () => { + // Load service worker like you would in the browser + require("../src/browser/register") + expect(mockRegisterFn).toHaveBeenCalled() + expect(mockRegisterFn).toHaveBeenCalledTimes(1) + }) + + it("should log an error if something doesn't work", () => { + const message = "Can't find browser" + const error = new Error(message) + + mockRegisterFn.mockImplementation(() => { + throw error + }) + + // Load service worker like you would in the browser + require("../src/browser/register") + + expect(mockRegisterFn).toHaveBeenCalled() + expect(loggerModule.logger.error).toHaveBeenCalled() + expect(loggerModule.logger.error).toHaveBeenCalledTimes(1) + expect(loggerModule.logger.error).toHaveBeenCalledWith( + `[Service Worker] registration: ${error.message} ${error.stack}`, + ) + }) + }) + + describe("when navigator and serviceWorker are NOT defined", () => { + let spy: jest.SpyInstance + + beforeEach(() => { + spy = jest.spyOn(console, "error") + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + it("should log an error to the console", () => { + // Load service worker like you would in the browser + require("../src/browser/register") + expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledTimes(1) + expect(spy).toHaveBeenCalledWith("[Service Worker] navigator is undefined") + }) + }) +}) diff --git a/test/serviceWorker.test.ts b/test/serviceWorker.test.ts new file mode 100644 index 00000000..7933d1f4 --- /dev/null +++ b/test/serviceWorker.test.ts @@ -0,0 +1,92 @@ +interface MockEvent { + claim: jest.Mock + waitUntil?: jest.Mock +} + +interface Listener { + event: string + cb: (event?: MockEvent) => void +} + +describe("serviceWorker", () => { + let listeners: Listener[] = [] + let spy: jest.SpyInstance + let claimSpy: jest.Mock + let waitUntilSpy: jest.Mock + + function emit(event: string) { + listeners + .filter((listener) => listener.event === event) + .forEach((listener) => { + switch (event) { + case "activate": + listener.cb({ + claim: jest.fn(), + waitUntil: jest.fn(() => waitUntilSpy()), + }) + break + default: + listener.cb() + } + }) + } + + beforeEach(() => { + claimSpy = jest.fn() + spy = jest.spyOn(console, "log") + waitUntilSpy = jest.fn() + + Object.assign(global, { + self: global, + addEventListener: (event: string, cb: () => void) => { + listeners.push({ event, cb }) + }, + clients: { + claim: claimSpy.mockResolvedValue("claimed"), + }, + }) + }) + + afterEach(() => { + jest.restoreAllMocks() + jest.resetModules() + spy.mockClear() + claimSpy.mockClear() + + // Clear all the listeners + listeners = [] + }) + + it("should add 3 listeners: install, activate and fetch", () => { + require("../src/browser/serviceWorker.ts") + const listenerEventNames = listeners.map((listener) => listener.event) + + expect(listeners).toHaveLength(3) + expect(listenerEventNames).toContain("install") + expect(listenerEventNames).toContain("activate") + expect(listenerEventNames).toContain("fetch") + }) + + it("should call the proper callbacks for 'install'", async () => { + require("../src/browser/serviceWorker.ts") + emit("install") + expect(spy).toHaveBeenCalledWith("[Service Worker] installed") + expect(spy).toHaveBeenCalledTimes(1) + }) + + it("should do nothing when 'fetch' is called", async () => { + require("../src/browser/serviceWorker.ts") + emit("fetch") + expect(spy).not.toHaveBeenCalled() + }) + + it("should call the proper callbacks for 'activate'", async () => { + require("../src/browser/serviceWorker.ts") + emit("activate") + + // Activate serviceWorker + expect(spy).toHaveBeenCalledWith("[Service Worker] activated") + expect(waitUntilSpy).toHaveBeenCalled() + expect(claimSpy).toHaveBeenCalled() + }) +}) diff --git a/test/util.test.ts b/test/util.test.ts index 0de2d7ea..2681d877 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,8 +1,4 @@ import { JSDOM } from "jsdom" -import { Cookie } from "playwright" -// Note: we need to import logger from the root -// because this is the logger used in logError in ../src/common/util -import { logger } from "../node_modules/@coder/logger" import { arrayify, generateUuid, @@ -18,14 +14,16 @@ import { import { Cookie as CookieEnum } from "../src/node/routes/login" import { hash } from "../src/node/util" import { PASSWORD } from "./constants" -import { checkForCookie, createCookieIfDoesntExist } from "./helpers" +import { checkForCookie, createCookieIfDoesntExist, loggerModule, Cookie } from "./helpers" const dom = new JSDOM() global.document = dom.window.document -// global.window = (dom.window as unknown) as Window & typeof globalThis type LocationLike = Pick +// jest.mock is hoisted above the imports so we must use `require` here. +jest.mock("@coder/logger", () => require("./helpers").loggerModule) + describe("util", () => { describe("normalize", () => { it("should remove multiple slashes", () => { @@ -229,12 +227,6 @@ describe("util", () => { }) describe("logError", () => { - let spy: jest.SpyInstance - - beforeEach(() => { - spy = jest.spyOn(logger, "error") - }) - afterEach(() => { jest.clearAllMocks() }) @@ -249,15 +241,15 @@ describe("util", () => { logError("ui", error) - expect(spy).toHaveBeenCalled() - expect(spy).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) + expect(loggerModule.logger.error).toHaveBeenCalled() + expect(loggerModule.logger.error).toHaveBeenCalledWith(`ui: ${error.message} ${error.stack}`) }) it("should log an error, even if not an instance of error", () => { logError("api", "oh no") - expect(spy).toHaveBeenCalled() - expect(spy).toHaveBeenCalledWith("api: oh no") + expect(loggerModule.logger.error).toHaveBeenCalled() + expect(loggerModule.logger.error).toHaveBeenCalledWith("api: oh no") }) })