diff --git a/web-admin/.gitignore b/web-admin/.gitignore index 0df9c1f6145..fa23d103cc8 100644 --- a/web-admin/.gitignore +++ b/web-admin/.gitignore @@ -1,2 +1,3 @@ /.svelte-kit -build \ No newline at end of file +build +tests/embed.html \ No newline at end of file diff --git a/web-admin/src/features/embeds/ExploreEmbed.svelte b/web-admin/src/features/embeds/ExploreEmbed.svelte index e46a442081c..fbad931a1af 100644 --- a/web-admin/src/features/embeds/ExploreEmbed.svelte +++ b/web-admin/src/features/embeds/ExploreEmbed.svelte @@ -14,6 +14,7 @@ }); $: ({ isSuccess, isError, error, data } = $explore); $: isExploreNotFound = isError && error?.response?.status === 404; + // We check for explore.state.validSpec instead of meta.reconcileError. validSpec persists // from previous valid explores, allowing display even when the current explore spec is invalid // and a meta.reconcileError exists. diff --git a/web-admin/src/features/embeds/init-embed-public-api.ts b/web-admin/src/features/embeds/init-embed-public-api.ts new file mode 100644 index 00000000000..982bbf377c0 --- /dev/null +++ b/web-admin/src/features/embeds/init-embed-public-api.ts @@ -0,0 +1,126 @@ +import { goto } from "$app/navigation"; +import { page } from "$app/stores"; +import { get, derived, type Readable } from "svelte/store"; + +import { getStateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; +import { convertExploreStateToURLSearchParams } from "@rilldata/web-common/features/dashboards/url-state/convertExploreStateToURLSearchParams"; +import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset"; +import { useMetricsViewTimeRange } from "@rilldata/web-common/features/dashboards/selectors"; + +import { + getTimeControlState, + type TimeControlState, +} from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import { + registerRPCMethod, + emitNotification, +} from "@rilldata/web-common/lib/rpc"; + +export default function initEmbedPublicAPI(instanceId: string): () => void { + const { + metricsViewName, + validSpecStore, + dashboardStore, + timeRangeSummaryStore, + } = getStateManagers(); + + const metricsViewNameValue = get(metricsViewName); + const metricsViewTimeRange = useMetricsViewTimeRange( + instanceId, + metricsViewNameValue, + ); + + const derivedState: Readable = derived( + [ + validSpecStore, + dashboardStore, + timeRangeSummaryStore, + metricsViewTimeRange, + ], + ([ + $validSpecStore, + $dashboardStore, + $timeRangeSummaryStore, + $metricsViewTimeRange, + ]) => { + const exploreSpec = $validSpecStore.data?.explore ?? {}; + const metricsViewSpec = $validSpecStore.data?.metricsView ?? {}; + + const defaultExplorePreset = getDefaultExplorePreset( + exploreSpec, + $metricsViewTimeRange?.data, + ); + + let timeControlsState: TimeControlState | undefined = undefined; + if (metricsViewSpec && exploreSpec && $dashboardStore) { + timeControlsState = getTimeControlState( + metricsViewSpec, + exploreSpec, + $timeRangeSummaryStore.data?.timeRangeSummary, + $dashboardStore, + ); + } + + return decodeURIComponent( + convertExploreStateToURLSearchParams( + $dashboardStore, + exploreSpec, + timeControlsState, + defaultExplorePreset, + ), + ); + }, + ); + + const unsubscribe = derivedState.subscribe((stateString) => { + emitNotification("stateChange", { state: stateString }); + }); + + registerRPCMethod("getState", () => { + const validSpec = get(validSpecStore); + const dashboard = get(dashboardStore); + const timeSummary = get(timeRangeSummaryStore).data; + const metricsTime = get(metricsViewTimeRange); + + const exploreSpec = validSpec.data?.explore ?? {}; + const metricsViewSpec = validSpec.data?.metricsView ?? {}; + + const defaultExplorePreset = getDefaultExplorePreset( + exploreSpec, + metricsTime?.data, + ); + + let timeControlsState: TimeControlState | undefined = undefined; + if (metricsViewSpec && exploreSpec && dashboard) { + timeControlsState = getTimeControlState( + metricsViewSpec, + exploreSpec, + timeSummary?.timeRangeSummary, + dashboard, + ); + } + const stateString = decodeURIComponent( + convertExploreStateToURLSearchParams( + dashboard, + exploreSpec, + timeControlsState, + defaultExplorePreset, + ), + ); + return { state: stateString }; + }); + + registerRPCMethod("setState", (state: string) => { + if (typeof state !== "string") { + return new Error("Expected state to be a string"); + } + const currentUrl = new URL(get(page).url); + currentUrl.search = state; + void goto(currentUrl, { replaceState: true }); + return true; + }); + + emitNotification("ready"); + + return unsubscribe; +} diff --git a/web-admin/src/routes/-/embed/+layout.svelte b/web-admin/src/routes/-/embed/+layout.svelte index a242293366b..1b803890733 100644 --- a/web-admin/src/routes/-/embed/+layout.svelte +++ b/web-admin/src/routes/-/embed/+layout.svelte @@ -1,12 +1,18 @@ ((resolve) => { + embedPage.on("console", async (msg) => { + if (msg.type() === "log") { + const args = await Promise.all( + msg.args().map((arg) => arg.jsonValue()), + ); + const logMessage = JSON.stringify(args); + logMessages.push(logMessage); + if (logMessage.includes(`{"method":"ready"}`)) { + resolve(); + } + } + }); + }); +} + +test.describe("Embeds", () => { + test("embeds should load", async ({ embedPage }) => { + const frame = embedPage.frameLocator("iframe"); + + await frame.getByLabel("Timezone selector").click(); + await frame.getByRole("menuitem", { name: "UTC GMT +00:00 UTC" }).click(); + + await expect( + frame.getByRole("button", { name: "Advertising Spend Overall $1.30M" }), + ).toBeVisible(); + }); + + test("state is emitted for embeds", async ({ embedPage }) => { + const logMessages: string[] = []; + await waitForReadyMessage(embedPage, logMessages); + const frame = embedPage.frameLocator("iframe"); + + await frame.getByLabel("Timezone selector").click(); + await frame.getByRole("menuitem", { name: "UTC GMT +00:00 UTC" }).click(); + + await frame.getByRole("row", { name: "Instacart $107.3k" }).click(); + await embedPage.waitForTimeout(500); + + expect( + logMessages.some((msg) => + msg.includes("tz=UTC&f=advertiser_name+IN+('Instacart')"), + ), + ).toBeTruthy(); + }); + + test("getState returns from embed", async ({ embedPage }) => { + const logMessages: string[] = []; + await waitForReadyMessage(embedPage, logMessages); + const frame = embedPage.frameLocator("iframe"); + + await frame.getByLabel("Timezone selector").click(); + await frame.getByRole("menuitem", { name: "UTC GMT +00:00 UTC" }).click(); + + await frame.getByRole("row", { name: "Instacart $107.3k" }).click(); + await embedPage.waitForTimeout(500); + + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage({ id: 1337, method: "getState" }, "*"); + }); + + await embedPage.waitForTimeout(500); + expect( + logMessages.some((msg) => + msg.includes( + `{"id":1337,"result":{"state":"tz=UTC&f=advertiser_name+IN+('Instacart')"}}`, + ), + ), + ).toBeTruthy(); + }); + + test("setState changes embedded explore", async ({ embedPage }) => { + const logMessages: string[] = []; + await waitForReadyMessage(embedPage, logMessages); + const frame = embedPage.frameLocator("iframe"); + + await embedPage.evaluate(() => { + const iframe = document.querySelector("iframe"); + iframe?.contentWindow?.postMessage( + { + id: 1337, + method: "setState", + params: "tz=UTC&f=advertiser_name+IN+('Instacart')", + }, + "*", + ); + }); + + await expect(frame.getByLabel("Timezone selector")).toHaveText("UTC"); + await expect( + frame.getByRole("row", { name: "Instacart $107.3k" }), + ).toBeVisible(); + expect( + logMessages.some((msg) => msg.includes(`{"id":1337,"result":true}`)), + ).toBeTruthy(); + }); +}); diff --git a/web-admin/tests/setup/base.ts b/web-admin/tests/setup/base.ts index 6367873d0e3..48d0463d0ea 100644 --- a/web-admin/tests/setup/base.ts +++ b/web-admin/tests/setup/base.ts @@ -1,12 +1,22 @@ import { test as base, type Page } from "@playwright/test"; import { ADMIN_STORAGE_STATE, VIEWER_STORAGE_STATE } from "./constants"; import { cliLogin, cliLogout } from "./fixtures/cli"; +import path from "path"; +import { fileURLToPath } from "url"; +import { + RILL_EMBED_SERVICE_TOKEN_FILE, + RILL_ORG_NAME, + RILL_PROJECT_NAME, +} from "./constants"; +import fs from "fs"; +import { generateEmbed } from "../utils/generate-embed"; type MyFixtures = { adminPage: Page; viewerPage: Page; anonPage: Page; cli: void; + embedPage: Page; }; export const test = base.extend({ @@ -44,4 +54,34 @@ export const test = base.extend({ await use(); await cliLogout(); }, + + embedPage: [ + async ({ browser }, use) => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const readPath = path.join( + __dirname, + "..", + "..", + RILL_EMBED_SERVICE_TOKEN_FILE, + ); + const rillServiceToken = fs.readFileSync(readPath, "utf-8"); + + await generateEmbed( + RILL_ORG_NAME, + RILL_PROJECT_NAME, + "bids_explore", + rillServiceToken, + ); + const filePath = "file://" + path.resolve(__dirname, "..", "embed.html"); + + const context = await browser.newContext(); + const embedPage = await context.newPage(); + await embedPage.goto(filePath); + + await use(embedPage); + + await context.close(); + }, + { scope: "test" }, + ], }); diff --git a/web-admin/tests/setup/constants.ts b/web-admin/tests/setup/constants.ts index 34c0b03e26d..b2bb425a8cf 100644 --- a/web-admin/tests/setup/constants.ts +++ b/web-admin/tests/setup/constants.ts @@ -4,3 +4,8 @@ export const VIEWER_STORAGE_STATE = "playwright/.auth/viewer.json"; export const RILL_DEVTOOL_BACKGROUND_PROCESS_PID_FILE = "playwright/rill-devtool-background-process-pid.txt"; +export const RILL_EMBED_SERVICE_TOKEN_FILE = + "playwright/rill-service-token.txt"; +export const RILL_ORG_NAME = "e2e"; +export const RILL_PROJECT_NAME = "openrtb"; +export const RILL_SERVICE_NAME = "e2e"; diff --git a/web-admin/tests/setup/setup.ts b/web-admin/tests/setup/setup.ts index b8873d9b109..ce4974e1f95 100644 --- a/web-admin/tests/setup/setup.ts +++ b/web-admin/tests/setup/setup.ts @@ -14,6 +14,10 @@ import { test as setup } from "./base"; import { ADMIN_STORAGE_STATE, RILL_DEVTOOL_BACKGROUND_PROCESS_PID_FILE, + RILL_EMBED_SERVICE_TOKEN_FILE, + RILL_ORG_NAME, + RILL_PROJECT_NAME, + RILL_SERVICE_NAME, } from "./constants"; import { cliLogin } from "./fixtures/cli"; @@ -136,15 +140,28 @@ setup.describe("global setup", () => { await page.context().storageState({ path: ADMIN_STORAGE_STATE }); }); - setup("should create an organization", async ({ adminPage }) => { + setup("should create an organization and service", async ({ adminPage }) => { // Create an organization named "e2e" await cliLogin(adminPage); - const { stdout: orgCreateStdout } = await execAsync("rill org create e2e"); + const { stdout: orgCreateStdout } = await execAsync( + `rill org create ${RILL_ORG_NAME}`, + ); expect(orgCreateStdout).toContain("Created organization"); + // create service and write access token to file + const { stdout: orgCreateService } = await execAsync( + `rill service create ${RILL_SERVICE_NAME}`, + ); + expect(orgCreateService).toContain("Created service"); + + const serviceToken = orgCreateService.match(/Access token:\s+(\S+)/); + writeFileEnsuringDir(RILL_EMBED_SERVICE_TOKEN_FILE, serviceToken![1]); + // Go to the organization's page - await adminPage.goto("/e2e"); - await expect(adminPage.getByRole("heading", { name: "e2e" })).toBeVisible(); + await adminPage.goto(`/${RILL_ORG_NAME}`); + await expect( + adminPage.getByRole("heading", { name: RILL_ORG_NAME }), + ).toBeVisible(); }); setup("should deploy the OpenRTB project", async ({ adminPage }) => { @@ -164,7 +181,7 @@ setup.describe("global setup", () => { "--subpath", "rill-openrtb-prog-ads", "--project", - "openrtb", + RILL_PROJECT_NAME, "--github", "--interactive=false", ], @@ -183,13 +200,13 @@ setup.describe("global setup", () => { await adminPage.waitForTimeout(10000); // Expect to see the successful deployment - await adminPage.goto("/e2e/openrtb"); + await adminPage.goto(`/${RILL_ORG_NAME}/${RILL_PROJECT_NAME}`); await expect( adminPage.getByText("Your trial expires in 30 days"), ).toBeVisible(); // Billing banner - await expect(adminPage.getByText("e2e")).toBeVisible(); // Organization breadcrumb + await expect(adminPage.getByText(RILL_ORG_NAME)).toBeVisible(); // Organization breadcrumb await expect(adminPage.getByText("Free trial")).toBeVisible(); // Billing status - await expect(adminPage.getByText("openrtb")).toBeVisible(); // Project breadcrumb + await expect(adminPage.getByText(RILL_PROJECT_NAME)).toBeVisible(); // Project breadcrumb // Check that the dashboards are listed await expect( @@ -212,6 +229,19 @@ setup.describe("global setup", () => { { intervals: Array(24).fill(5_000), timeout: 180_000 }, ) .toContain("Last refreshed"); + + await expect + .poll( + async () => { + await adminPage.reload(); + const listing = adminPage.getByRole("link", { + name: "Programmatic Ads Bids bids_explore", + }); + return listing.textContent(); + }, + { intervals: Array(24).fill(5_000), timeout: 180_000 }, + ) + .toContain("Last refreshed"); }); }); diff --git a/web-admin/tests/utils/generate-embed.ts b/web-admin/tests/utils/generate-embed.ts new file mode 100644 index 00000000000..022a291a339 --- /dev/null +++ b/web-admin/tests/utils/generate-embed.ts @@ -0,0 +1,61 @@ +import type { AxiosResponse } from "axios"; +import axios from "axios"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +export async function generateEmbed( + organization: string, + project: string, + resourceName: string, + serviceToken: string, +): Promise { + try { + const response: AxiosResponse<{ iframeSrc: string }> = await axios.post( + `http://localhost:8080/v1/organizations/${organization}/projects/${project}/iframe`, + { + resource: resourceName, + navigation: true, + }, + { + headers: { + Authorization: `Bearer ${serviceToken}`, + "Content-Type": "application/json", + }, + }, + ); + + const iframeSrc = response.data.iframeSrc; + if (!iframeSrc) { + throw new Error("Invalid response: iframeSrc not found"); + } + + const htmlContent = ` + + + + + Iframe Example + + + + + +`; + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const outputPath = path.join(__dirname, "..", "embed.html"); + + fs.writeFileSync(outputPath, htmlContent, "utf8"); + } catch (error: unknown) { + if (error instanceof Error) { + console.error("Error fetching iframe or saving file:", error.message); + } else { + console.error("An unknown error occurred:", error); + } + } +} diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index a4a115f6427..20486c42246 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -19,6 +19,7 @@ import { useTimeControlStore } from "../time-controls/time-control-store"; import TimeDimensionDisplay from "../time-dimension-details/TimeDimensionDisplay.svelte"; import MetricsTimeSeriesCharts from "../time-series/MetricsTimeSeriesCharts.svelte"; + import { onMount, tick } from "svelte"; export let exploreName: string; export let metricsViewName: string; @@ -95,6 +96,28 @@ let metricsWidth = DEFAULT_TIMESERIES_WIDTH; let resizing = false; + + let initEmbedPublicAPI; + + // Hacky solution to ensure that the embed public API is initialized after the dashboard is fully loaded + onMount(async () => { + if (isEmbedded) { + initEmbedPublicAPI = ( + await import( + "@rilldata/web-admin/features/embeds/init-embed-public-api" + ) + ).default; + } + await tick(); + }); + + $: if (initEmbedPublicAPI) { + try { + initEmbedPublicAPI(instanceId); + } catch (error) { + console.error("Error running initEmbedPublicAPI:", error); + } + }
Promise | unknown; +}; + +const methods: JSONRPCMethods = { + echo(message: { message: string }) { + return message; + }, +}; + +async function handleRPCMessage(event: MessageEvent) { + if (typeof event.data !== "object" || event.data === null) { + return sendError(null, JSONRPC_ERRORS.INVALID_REQUEST); + } + + const { id = null, method, params } = event.data; + + if (typeof method !== "string") { + return sendError(id, JSONRPC_ERRORS.INVALID_REQUEST); + } + + if (!methods[method]) { + return sendError(id, JSONRPC_ERRORS.METHOD_NOT_FOUND); + } + + try { + const result = await methods[method](params); + if (id !== null) { + sendResponse(id, result); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + sendError(id, { + code: JSONRPC_ERRORS.INTERNAL_ERROR.code, + message: errorMessage, + }); + } +} + +function sendResponse(id: string | number | null, result: unknown) { + if (window.parent !== window) { + window.parent.postMessage({ id, result } as JSONRPCResponse, "*"); + } +} + +function sendError( + id: string | number | null, + error: { code: number; message: string; data?: unknown }, +) { + if (window.parent !== window) { + window.parent.postMessage({ id, error } as JSONRPCResponse, "*"); + } +} + +export function createIframeRPCHandler() { + const handler = (event: MessageEvent) => { + if (event.source && event.data) { + void handleRPCMessage(event as MessageEvent); + } + }; + + window.addEventListener("message", handler); + + return () => { + window.removeEventListener("message", handler); + }; +} + +export function registerRPCMethod( + name: string, + func: (params: T) => Promise | unknown, +) { + methods[name] = func; +} + +export function emitNotification(method: string, params?: unknown) { + if (window.parent !== window) { + window.parent.postMessage({ method, params } as JSONRPCRequest, "*"); + } +}