Skip to content

Commit

Permalink
Mod variable session sync
Browse files Browse the repository at this point in the history
Add state controller test

9154: add E2E runtime test

Improve comments

Refactor dipatch method

Fix type errors

Fix overzealous exception
  • Loading branch information
twschiller committed Sep 23, 2024
1 parent 9a8cc58 commit b25b38d
Show file tree
Hide file tree
Showing 10 changed files with 434 additions and 49 deletions.
95 changes: 95 additions & 0 deletions end-to-end-tests/tests/runtime/modVariables/variableSync.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { test, expect } from "../../../fixtures/testBase";
import { ActivateModPage } from "../../../pageObjects/extensionConsole/modsPage";
// @ts-expect-error -- https://youtrack.jetbrains.com/issue/AQUA-711/Provide-a-run-configuration-for-Playwright-tests-in-specs-with-fixture-imports-only
import { type Frame, type Page, test as base } from "@playwright/test";
import { getSidebarPage } from "../../../utils";

test.describe("Mod Variable Sync", () => {
test("session variable cross-tab sync", async ({ page, extensionId }) => {
await test.step("activate mod", async () => {
const modId = "@e2e-testing/state-sync";
const modActivationPage = new ActivateModPage(page, extensionId, modId);
await modActivationPage.goto();
await modActivationPage.clickActivateAndWaitForModsPageRedirect();

await page.goto("/frames-builder.html");
});

// Waiting for the mod to be ready before opening sidebar
await expect(page.getByText("Local: 0")).toBeVisible();

// The mod contains a trigger to open the sidebar on h1
await page.click("h1");
const sideBarPage = await getSidebarPage(page, extensionId);
await expect(
sideBarPage.getByRole("heading", { name: "State Sync Demo" }),
).toBeVisible();

await test.step("verify same tab increment", async () => {
await sideBarPage.getByRole("button", { name: "Increment" }).click();

await expect(sideBarPage.getByText("Sync: 1")).toBeVisible();
await expect(sideBarPage.getByText("Local: 1")).toBeVisible();

await expect(page.getByText("Sync: 1")).toBeVisible();
await expect(page.getByText("Local: 1")).toBeVisible();

const frameLocator = page.frameLocator("iframe");
await expect(frameLocator.getByText("Sync: 1")).toBeVisible();
await expect(frameLocator.getByText("Local: 0")).toBeVisible();
});

// Close the sidebar, because getSidebarPage currently can't distinguish between multiple sidebars
await sideBarPage.getByRole("button", { name: "Close" }).click();
await sideBarPage.getByRole("button", { name: "Close" }).click();

const otherPage = await page.context().newPage();
await otherPage.goto(page.url());

// Waiting for the mod to be ready before opening sidebar
await expect(otherPage.getByText("Local: 0")).toBeVisible();

await otherPage.click("h1");
const otherSideBarPage = await getSidebarPage(otherPage, extensionId);
await expect(
otherSideBarPage.getByRole("heading", { name: "State Sync Demo" }),
).toBeVisible();

await test.step("verify cross tab increment", async () => {
// Should be available on first run of the panel
await expect(otherSideBarPage.getByText("Sync: 1")).toBeVisible();
await expect(otherSideBarPage.getByText("Local: ")).toBeVisible();

await otherSideBarPage.getByRole("button", { name: "Increment" }).click();

await expect(otherSideBarPage.getByText("Sync: 2")).toBeVisible();
await expect(otherSideBarPage.getByText("Local: 1")).toBeVisible();

// Should automatically sync to the original tab
await expect(page.getByText("Sync: 2")).toBeVisible();
await expect(page.getByText("Local: 1")).toBeVisible();

const frameLocator = page.frameLocator("iframe");
await expect(frameLocator.getByText("Sync: 2")).toBeVisible();
// Local variable doesn't exist in the frame
await expect(frameLocator.getByText("Local: 0")).toBeVisible();
});
});
});
14 changes: 11 additions & 3 deletions end-to-end-tests/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,18 @@ export async function runModViaQuickBar(page: Page, modName: string) {
}

function findSidebarPage(page: Page, extensionId: string): Page | undefined {
return page
const matches = page
.context()
.pages()
.find((value) =>
.filter((value) =>
value.url().startsWith(`chrome-extension://${extensionId}/sidebar.html`),
);

if (matches.length > 1) {
throw new Error("Multiple sidebar pages found");
}

return matches[0];
}

/**
Expand All @@ -111,10 +117,12 @@ export function isSidebarOpen(page: Page, extensionId: string): boolean {
/**
* Finds the Pixiebrix sidebar page/frame.
*
* NOTE: returns the sidebar found, not necessarily the one for the provided page.
*
* Automatically clicks "OK" on the dialog that appears if the sidebar requires a user gesture to open
* This is a Page contained in the browser sidepanel window.
*
* @throws {Error} if the sidebar is not available
* @throws {Error} if the sidebar is not available or multiple pages have the sidebar open
*/
export async function getSidebarPage(
page: Page,
Expand Down
8 changes: 8 additions & 0 deletions src/background/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ import initTeamTrialUpdater from "@/background/teamTrialUpdater";
// In the future, it might also run other background tasks from mods (e.g., background intervals)
setPlatform(backgroundPlatform);

// Allows the content script to directly access the session storage for mod variables. Without this, we'd need to
// we'd need to use the messenger or localStorage to synchronize state across frames.
// Does not trigger a permissions prompt, see:
// https://developer.chrome.com/docs/extensions/reference/api/storage#type-AccessLevel
void chrome.storage.session.setAccessLevel({
accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
});

void initLocator();
void initMessengerLogging();
void initRuntimeLogging();
Expand Down
7 changes: 6 additions & 1 deletion src/contentScript/contentScriptPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ import {
traces,
uninstallContextMenu,
} from "@/background/messenger/api";
import { getState, setState } from "@/contentScript/stateController";
import {
getState,
registerModVariables,
setState,
} from "@/contentScript/stateController";
import quickBarRegistry from "@/components/quickBar/quickBarRegistry";
import { expectContext } from "@/utils/expectContext";
import type { PlatformCapability } from "@/platform/capabilities";
Expand Down Expand Up @@ -242,6 +246,7 @@ class ContentScriptPlatform extends PlatformBase {
return {
getState,
setState,
registerModVariables,
};
}

Expand Down
58 changes: 57 additions & 1 deletion src/contentScript/stateController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { setState } from "@/contentScript/stateController";
import {
setState,
registerModVariables,
getState,
} from "@/contentScript/stateController";
import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories";
import {
MergeStrategies,
STATE_CHANGE_JS_EVENT_TYPE,
StateNamespaces,
} from "@/platform/state/stateTypes";
import type { JSONSchema7Definition } from "json-schema";

describe("pageState", () => {
it("deep merge triggers event", async () => {
Expand Down Expand Up @@ -70,4 +75,55 @@ describe("pageState", () => {
asyncState: { isFetching: true, currentData: null, data: "foo" },
});
});

describe("mod variable policy", () => {
it("stores variable in session storage", async () => {
const listener = jest.fn();

document.addEventListener(STATE_CHANGE_JS_EVENT_TYPE, listener);

const modComponentRef = modComponentRefFactory();

await expect(browser.storage.session.get(null)).resolves.toStrictEqual(
{},
);

registerModVariables(modComponentRef.modId, {
schema: {
type: "object",
properties: {
foo: {
type: "object",
"x-sync-policy": "session",
// Cast required because types don't support custom `x-` variables yet
} as JSONSchema7Definition,
},
required: ["foo"],
},
});

await setState({
namespace: StateNamespaces.MOD,
data: { foo: { bar: "baz" }, quox: 42 },
mergeStrategy: MergeStrategies.REPLACE,
modComponentRef,
});

// The storage fake doesn't emit events
expect(listener).toHaveBeenCalledTimes(0);

const values = await browser.storage.session.get(null);

// Ensure values are segmented correctly in storage
expect(JSON.stringify(values)).not.toContain("quox");
expect(JSON.stringify(values)).toContain("bar");

const state = await getState({
namespace: StateNamespaces.MOD,
modComponentRef,
});

expect(state).toEqual({ foo: { bar: "baz" }, quox: 42 });
});
});
});
Loading

0 comments on commit b25b38d

Please sign in to comment.