Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onUpdate event listener for installing starter blueprints on Playground urls #4249

Merged
merged 21 commits into from
Sep 8, 2022
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 45 additions & 20 deletions src/background/starterBlueprints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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);

Expand Down Expand Up @@ -55,10 +55,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);
Expand All @@ -68,10 +73,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);
Expand All @@ -81,10 +91,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);
Expand All @@ -94,10 +107,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);
Expand All @@ -107,22 +125,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);
Expand Down
121 changes: 86 additions & 35 deletions src/background/starterBlueprints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,71 +26,122 @@ import reportError from "@/telemetry/reportError";

const { reducer, actions } = extensionsSlice;

function installStarterBlueprint(
const PLAYGROUND_URL = "https://www.pixiebrix.com/playground";

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<void> {
async function installBlueprints(
Copy link
Contributor

@fregante fregante Sep 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To ensure that it's not called undebounced (as it currently is on line 150), we can prepend _ to the name.

I generally inline the function in the debounce definition (debounce(() => {}, opts)) but for long functions it's less readable.

Suggested change
async function installBlueprints(
async function _installBlueprints(

blueprints: RecipeDefinition[]
): Promise<boolean> {
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<boolean> {
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: starterBlueprints } = await client.get<RecipeDefinition[]>(
"/api/onboarding/starter-blueprints/"
);
const {
data: { install_starter_blueprints: shouldInstall },
} = await client.get("/api/onboarding/starter-blueprints/install/");

// 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;
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/");
}

let extensionsState = await loadOptions();
return shouldInstall;
} catch (error) {
reportError(error);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is going to be returned from this function when it catches an error? I think void will be coerced to false, but that's not really clear from looking at the code. Can we please return false here explicitly to make it more clear?

Copy link
Contributor

@fregante fregante Sep 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 TypeScript in loose mode misses a lot of such warnings. It should have complained that the signature is boolean but there's no return here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this Ben! This was indeed a mistake.

}
}

for (const starterBlueprint of starterBlueprints) {
const blueprintAlreadyInstalled = extensionsState.extensions.some(
(extension) => extension._recipe.id === starterBlueprint.metadata.id
);
async function getStarterBlueprints(): Promise<RecipeDefinition[]> {
const client = await maybeGetLinkedApiClient();
if (client == null) {
console.debug(
"Skipping starter blueprint installation because the extension is not linked to the PixieBrix service"
);
return [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💙

}

if (!blueprintAlreadyInstalled) {
extensionsState = installStarterBlueprint(
extensionsState,
starterBlueprint
);
}
}
try {
const { data: starterBlueprints } = await client.get<RecipeDefinition[]>(
"/api/onboarding/starter-blueprints/"
);
return starterBlueprints;
} catch (error) {
reportError(error);
return [];
}
}

async function installStarterBlueprints(): Promise<boolean> {
const starterBlueprints = await getStarterBlueprints();
return installBlueprints(starterBlueprints);
}

export async function firstTimeInstallStarterBlueprints(): Promise<void> {
const shouldInstall = await getShouldFirstTimeInstall();
if (!shouldInstall) {
return;
}

await saveOptions(extensionsState);
const installed = await installStarterBlueprints();
Copy link
Contributor

@fregante fregante Sep 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While unlikely, it could be called concurrently with browser.tabs.onUpdated. It doesn't hurt to use the debounced version everywhere to avoid these leaks


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 installStarterBlueprints();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my comment got lost:

This listener will run installStarterBlueprints every time the playground is opened, for any number of open tabs, and every time a new page is visited within the playground. Given it contains some async getter/setters, this will certainly cause trouble (as well as unnecessary computation)

How about this pattern:

async function initStarterBlueprints() {
	if (await isInstalled()) {
		return
	}

	if (await shouldInstall() || await onPlayGroundOpen()) {
		install()
	}
}

async function onPlayGroundOpen() {
	return new Promise(resolve => {
		const listener = (id, info, tab) => {
			if (tab?.url?.startsWith(PLAYGROUND_URL)) {
				browser.tabs.onUpdated.removeListener(listener)
				resolve(true) 
			}
		}

		browser.tabs.onUpdated.addListener(listener) 
	})
}

To note:

  • initStarterBlueprints is only run once and may be "pending" forever, or until the user opens the playground

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function will run, but I think the check on line 54 prevents any blueprint from being installed twice, right? What kind of trouble are you imagining this will cause? Can you explain how your code example here avoids that trouble?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

browser.tabs.onUpdated fires a lot of times with all kinds of updates, even favicon updates, status updates, url updates.

  1. installStarterBlueprints is called the first time
  2. It starts an API call via getStarterBlueprints
  3. installStarterBlueprints is called a second time while the API call is still in progress
  4. It starts another API call

At this point line 54 hasn't been reached yet, so any logic there is futile.

My example avoids the trouble because it does call installStarterBlueprints every time the event happens; the listener is only used once to resolve a promise, and then the listener is removed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of trouble are you imagining this will cause?

Some possibilities:

  • each client reporting the same error multiple times on line 112
  • each client making multiple unnecessary calls to the server, further slowing the server down
  • loadOptions being called twice simultaneously, and then saveOptions overriding each other (race condition, almost exactly what was described in Verify that recordEvent correctly handles concurrent events #4073)

Copy link
Collaborator Author

@mnholtz mnholtz Sep 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fregante I'm not sure the pattern you suggested fits the needs of the feature, unless I'm misunderstanding your pseudocode. initStarterBlueprints will run only once per extension launch. Therefore, the conditional if (await shouldInstall() || await onPlayGroundOpen()) { will also be run only once. Subsequent visits to the Playground page after uninstalling Playground blueprints/or a failed installation will not work. I'm actually not sure why the event listener is being re-added in the pseudocode if the conditional is run only once anyways.

We need users to always have Starter Blueprints installed for them when visiting the Playground page, to catch the cases if they were to uninstall the Blueprints and revisit the Playground, or if an error like a network request failed to install them the first time. In this case, I think I might go with a locking (and/or maybe debounce?) pattern in order to solve the concerns you outlined above.

Copy link
Contributor

@fregante fregante Sep 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need users to always have Starter Blueprints installed for them when visiting the Playground page

I didn't think about that, although it would only be hit if they visit the playground, delete the blueprint, and then visit the playground again — all while never quitting the browser.

I guess it's fine to debounce it to once per minute, that would work even better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the discussion Federico! Yeah, debounce sounds good to me also.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah thanks federico! Great considerations here

}
});

void firstTimeInstallStarterBlueprints();
}

export default initStarterBlueprints;