From 238f5ebf1f47a0cfbca314fa7fb5e6c60fd5e055 Mon Sep 17 00:00:00 2001 From: Misha Holtz <36575242+mnholtz@users.noreply.github.com> Date: Thu, 8 Sep 2022 16:36:26 -0700 Subject: [PATCH] Add onUpdate event listener for installing starter blueprints on Playground urls (#4249) * add tabs onUpdated event listener for starter blueprints * install stater blueprints on playground urls * fix starter blueprint tests * refactor extract install starter blueprints loop * extract more installation methods * refactor installStarterBlueprints * update starter blueprint onUpdate url * fix starterBlueprint test * fix test typo * add installation lock * add debounce to starter blueprint installation for onUpdate events * remove trailing param * readd trailing * add boolean return on error * call debouncedInstallStarterBlueprints for first time installs also * fix jest tests for new debounce refactor with fake timers' Co-authored-by: Misha Holtz --- src/background/starterBlueprints.test.ts | 67 +++++++---- src/background/starterBlueprints.ts | 139 +++++++++++++++++------ 2 files changed, 153 insertions(+), 53 deletions(-) diff --git a/src/background/starterBlueprints.test.ts b/src/background/starterBlueprints.test.ts index d88bd7c986..9d1e329799 100644 --- a/src/background/starterBlueprints.test.ts +++ b/src/background/starterBlueprints.test.ts @@ -15,13 +15,13 @@ * along with this program. If not, see . */ -import { installStarterBlueprints } from "@/background/starterBlueprints"; +import { firstTimeInstallStarterBlueprints } from "@/background/starterBlueprints"; import { loadOptions, saveOptions } from "@/store/extensionsStorage"; import MockAdapter from "axios-mock-adapter"; import axios from "axios"; import { isLinked } from "@/auth/token"; import { extensionFactory, recipeFactory } from "@/testUtils/factories"; -import { PersistedExtension } from "@/core"; +import { PersistedExtension, RecipeMetadata } from "@/core"; const axiosMock = new MockAdapter(axios); @@ -38,9 +38,11 @@ jest.mock("@/background/util", () => ({ const isLinkedMock = isLinked as jest.Mock; const openPlaygroundPage = browser.tabs.create as jest.Mock; +jest.useFakeTimers(); beforeEach(async () => { jest.resetModules(); + jest.runAllTimers(); // Reset local options state await saveOptions({ @@ -55,10 +57,15 @@ describe("installStarterBlueprints", () => { test("user has starter blueprints available to install", async () => { isLinkedMock.mockResolvedValue(true); - axiosMock.onGet().reply(200, [recipeFactory()]); - axiosMock.onPost().reply(204); + axiosMock + .onGet("/api/onboarding/starter-blueprints/install/") + .reply(200, { install_starter_blueprints: true }); + axiosMock + .onGet("/api/onboarding/starter-blueprints/") + .reply(200, [recipeFactory()]); + axiosMock.onPost("/api/onboarding/starter-blueprints/install/").reply(204); - await installStarterBlueprints(); + await firstTimeInstallStarterBlueprints(); const { extensions } = await loadOptions(); expect(extensions.length).toBe(1); @@ -68,10 +75,15 @@ describe("installStarterBlueprints", () => { test("user does not have starter blueprints available to install", async () => { isLinkedMock.mockResolvedValue(true); - axiosMock.onGet().reply(200, []); - axiosMock.onPost().reply(204); + axiosMock + .onGet("/api/onboarding/starter-blueprints/install/") + .reply(200, { install_starter_blueprints: false }); + axiosMock + .onGet("/api/onboarding/starter-blueprints/") + .reply(200, [recipeFactory()]); + axiosMock.onPost("/api/onboarding/starter-blueprints/install/").reply(204); - await installStarterBlueprints(); + await firstTimeInstallStarterBlueprints(); const { extensions } = await loadOptions(); expect(extensions.length).toBe(0); @@ -81,10 +93,13 @@ describe("installStarterBlueprints", () => { test("starter blueprints request fails", async () => { isLinkedMock.mockResolvedValue(true); - axiosMock.onGet().reply(500); - axiosMock.onPost().reply(204); + axiosMock + .onGet("/api/onboarding/starter-blueprints/install/") + .reply(200, { install_starter_blueprints: true }); + axiosMock.onGet("/api/onboarding/starter-blueprints/").reply(500); + axiosMock.onPost("/api/onboarding/starter-blueprints/install/").reply(204); - await installStarterBlueprints(); + await firstTimeInstallStarterBlueprints(); const { extensions } = await loadOptions(); expect(extensions.length).toBe(0); @@ -94,10 +109,15 @@ describe("installStarterBlueprints", () => { test("starter blueprints installation request fails", async () => { isLinkedMock.mockResolvedValue(true); - axiosMock.onGet().reply(200, []); - axiosMock.onPost().reply(500); + axiosMock + .onGet("/api/onboarding/starter-blueprints/install/") + .reply(200, { install_starter_blueprints: true }); + axiosMock + .onGet("/api/onboarding/starter-blueprints/") + .reply(200, [recipeFactory()]); + axiosMock.onPost("/api/onboarding/starter-blueprints/install/").reply(500); - await installStarterBlueprints(); + await firstTimeInstallStarterBlueprints(); const { extensions } = await loadOptions(); expect(extensions.length).toBe(0); @@ -107,22 +127,29 @@ describe("installStarterBlueprints", () => { test("starter blueprint already installed", async () => { isLinkedMock.mockResolvedValue(true); - const extension = extensionFactory() as PersistedExtension; + const recipe = recipeFactory(); + + const extension = extensionFactory({ + _recipe: { id: recipe.metadata.id } as RecipeMetadata, + }) as PersistedExtension; await saveOptions({ extensions: [extension], }); - axiosMock.onGet().reply(200, [ + axiosMock + .onGet("/api/onboarding/starter-blueprints/install/") + .reply(200, { install_starter_blueprints: true }); + + axiosMock.onGet("/api/onboarding/starter-blueprints/").reply(200, [ { - updated_at: "", extensionPoints: [extension], - sharing: {}, + ...recipe, }, ]); - axiosMock.onPost().reply(204); + axiosMock.onPost("/api/onboarding/starter-blueprints/install/").reply(204); - await installStarterBlueprints(); + await firstTimeInstallStarterBlueprints(); const { extensions } = await loadOptions(); expect(extensions.length).toBe(1); diff --git a/src/background/starterBlueprints.ts b/src/background/starterBlueprints.ts index f0dc0f17ad..5d60338c35 100644 --- a/src/background/starterBlueprints.ts +++ b/src/background/starterBlueprints.ts @@ -23,74 +23,147 @@ import { forEachTab } from "@/background/util"; import { queueReactivateTab } from "@/contentScript/messenger/api"; import { ExtensionOptionsState } from "@/store/extensionsTypes"; import reportError from "@/telemetry/reportError"; +import { debounce } from "lodash"; const { reducer, actions } = extensionsSlice; -function installStarterBlueprint( +const PLAYGROUND_URL = "https://www.pixiebrix.com/playground"; +let isInstallingBlueprints = false; +const BLUEPRINT_INSTALLATION_DEBOUNCE_MS = 10_000; +const BLUEPRINT_INSTALLATION_MAX_MS = 60_000; + +function installBlueprint( state: ExtensionOptionsState, - starterBlueprint: RecipeDefinition + blueprint: RecipeDefinition ): ExtensionOptionsState { return reducer( state, actions.installRecipe({ - recipe: starterBlueprint, - extensionPoints: starterBlueprint.extensionPoints, + recipe: blueprint, + extensionPoints: blueprint.extensionPoints, }) ); } -export async function installStarterBlueprints(): Promise { +async function installBlueprints( + blueprints: RecipeDefinition[] +): Promise { + let installed = false; + if (blueprints.length === 0) { + return installed; + } + + let extensionsState = await loadOptions(); + for (const blueprint of blueprints) { + const blueprintAlreadyInstalled = extensionsState.extensions.some( + (extension) => extension._recipe.id === blueprint.metadata.id + ); + + if (!blueprintAlreadyInstalled) { + extensionsState = installBlueprint(extensionsState, blueprint); + installed = true; + } + } + + await saveOptions(extensionsState); + await forEachTab(queueReactivateTab); + return installed; +} + +async function getShouldFirstTimeInstall(): Promise { const client = await maybeGetLinkedApiClient(); if (client == null) { console.debug( "Skipping starter blueprint installation because the extension is not linked to the PixieBrix service" ); - return; + return false; + } + + try { + const { + data: { install_starter_blueprints: shouldInstall }, + } = await client.get("/api/onboarding/starter-blueprints/install/"); + + if (shouldInstall) { + // If the starter blueprint request fails for some reason, or the user's primary organization + // gets removed, we'd still like to mark starter blueprints as installed for this user + // so that they don't see onboarding views/randomly have starter blueprints installed + // the next time they open the extension + await client.post("/api/onboarding/starter-blueprints/install/"); + } + + return shouldInstall; + } catch (error) { + reportError(error); + return false; + } +} + +async function getStarterBlueprints(): Promise { + const client = await maybeGetLinkedApiClient(); + if (client == null) { + console.debug( + "Skipping starter blueprint installation because the extension is not linked to the PixieBrix service" + ); + return []; } try { const { data: starterBlueprints } = await client.get( "/api/onboarding/starter-blueprints/" ); + return starterBlueprints; + } catch (error) { + reportError(error); + return []; + } +} - // If the starter blueprint request fails for some reason, or the user's primary organization - // gets removed, we'd still like to mark starter blueprints as installed for this user - // so that they don't see onboarding views/randomly have starter blueprints installed - // the next time they open the extension - await client.post("/api/onboarding/starter-blueprints/install/"); - - if (starterBlueprints.length === 0) { - return; - } +const _installStarterBlueprints = async (): Promise => { + if (isInstallingBlueprints) { + return false; + } - let extensionsState = await loadOptions(); + isInstallingBlueprints = true; + const starterBlueprints = await getStarterBlueprints(); + const installed = await installBlueprints(starterBlueprints); + isInstallingBlueprints = false; + return installed; +}; - for (const starterBlueprint of starterBlueprints) { - const blueprintAlreadyInstalled = extensionsState.extensions.some( - (extension) => extension._recipe.id === starterBlueprint.metadata.id - ); +const debouncedInstallStarterBlueprints = debounce( + _installStarterBlueprints, + BLUEPRINT_INSTALLATION_DEBOUNCE_MS, + { + leading: true, + trailing: false, + maxWait: BLUEPRINT_INSTALLATION_MAX_MS, + } +); - if (!blueprintAlreadyInstalled) { - extensionsState = installStarterBlueprint( - extensionsState, - starterBlueprint - ); - } - } +export async function firstTimeInstallStarterBlueprints(): Promise { + const shouldInstall = await getShouldFirstTimeInstall(); + if (!shouldInstall) { + return; + } - await saveOptions(extensionsState); + const installed = await debouncedInstallStarterBlueprints(); - await forEachTab(queueReactivateTab); + if (installed) { void browser.tabs.create({ - url: "https://www.pixiebrix.com/playground", + url: PLAYGROUND_URL, }); - } catch (error) { - reportError(error); } } function initStarterBlueprints(): void { - void installStarterBlueprints(); + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (tab?.url?.startsWith(PLAYGROUND_URL)) { + void debouncedInstallStarterBlueprints(); + } + }); + + void firstTimeInstallStarterBlueprints(); } export default initStarterBlueprints;