Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
222 changes: 222 additions & 0 deletions e2e/scenarios/integrations-grid-grouping-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Integrations grid grouping: a provider whose plugin fans out into several
// per-service integrations (Google -> Calendar, Gmail, Drive, plus a custom
// Discovery URL) collapses those siblings under ONE provider umbrella on the
// integrations list, while every non-family integration (the built-in
// "executor" source) stays flat. Each per-service row keeps its OWN product
// glyph, so the icons in the group are visibly distinct rather than one
// repeated Google logo.
//
// This drives the browser path end to end:
// 1. Fan out four Google integrations through the Google add flow (the same
// real Discovery add path the google-per-service spec exercises).
// 2. On /integrations, assert a single "Google" umbrella (data-testid
// `integration-group-google`) contains all four per-service entries.
// 3. Assert the non-family "executor" integration renders OUTSIDE any group
// umbrella.
// 4. Assert Calendar, Gmail, and Drive resolve to three DISTINCT icon srcs
// (per-service favicons), not one shared provider logo.
//
// OUTBOUND DISCOVERY: like google-per-service-add-ui, the add step fetches real
// Google Discovery documents (www.googleapis.com, read-only, no credentials).
// The grouping assertions themselves are pure DOM and touch no external state.
//
// Text lookups are scoped to getByRole("main"): selfhost shares a
// bootstrap-admin identity and the shell sidebar also lists these integrations,
// so a page-wide getByText would match the sidebar copy.
//
// SHARED-STATE ROBUSTNESS: on selfhost every scenario acts as the same
// bootstrap admin, so google-per-service-add-ui may already have created these
// integrations before this file runs (or vice versa, in either order). This
// spec first checks the grid, adds only what is missing (re-submitting an
// existing service is a harmless "skipped" row), and removes ONLY what it
// created in an `ensuring` finalizer so the google-per-service spec finds a
// clean slate if it runs after this file.
import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { IntegrationSlug } from "@executor-js/sdk/shared";

import { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";
import { clearCheckedPresets, setPresetChecked } from "./support/picker";

const coreApi = composePluginApi([] as const);

scenario(
"Integrations · per-service integrations group under one provider umbrella with distinct icons",
{ timeout: 240_000 },
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client } = yield* Api;
const identity = yield* target.newIdentity();
const api = yield* client(coreApi, identity);
const customDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
// Slugs this test creates (as opposed to found already existing); the
// finalizer removes exactly these so no state leaks into the shared
// selfhost instance, and pre-existing integrations are left alone.
const createdSlugs: string[] = [];

const session = browser.session(identity, async ({ page, step }) => {
await step(
"Ensure the four Google integrations exist (Calendar, Gmail, Drive, custom)",
async () => {
// This step is about ensuring grid preconditions, not about the add
// path (google-per-service-add-ui owns that): it adds only the missing
// integrations and tolerates "skipped" rows. The real grouping/icon
// assertions live in the steps below.
//
// preset id -> integration slug (dashes become underscores). The
// custom Tasks Discovery URL derives its identity from the fetched
// Discovery doc: slug google_tasks, name "Google Tasks".
const presetToSlug: Record<string, string> = {
"google-calendar": "google_calendar",
"google-gmail": "google_gmail",
"google-drive": "google_drive",
};
const customSlug = "google_tasks";

await page.goto("/integrations", { waitUntil: "networkidle" });
const existingMain = page.getByRole("main");
const slugExists = async (slug: string): Promise<boolean> =>
(await existingMain.getByTestId(`integration-entry-${slug}`).count()) > 0;

const missingPresetIds: string[] = [];
for (const [presetId, slug] of Object.entries(presetToSlug)) {
if (!(await slugExists(slug))) missingPresetIds.push(presetId);
}
const customMissing = !(await slugExists(customSlug));

// Everything the grid asserts on already exists: the add flow's submit
// would be disabled (custom slug collision), so skip it entirely and
// go straight to the grid.
if (missingPresetIds.length === 0 && !customMissing) return;

// Record intent BEFORE submitting so a mid-add failure still gets its
// partial state cleaned up by the finalizer.
for (const presetId of missingPresetIds) createdSlugs.push(presetToSlug[presetId]!);
if (customMissing) createdSlugs.push(customSlug);

await page.goto("/integrations/add/google", {
waitUntil: "domcontentloaded",
});
await page.getByText("Customize your Google connection").waitFor();
await page.getByTestId("preset-checkbox-google-calendar").waitFor();

// Clear the featured defaults, then select exactly the missing presets:
// submitting an already-existing preset is harmless ("skipped") but
// leaving defaults checked would create integrations this test never
// asserts on.
await clearCheckedPresets(page);
for (const presetId of missingPresetIds) {
await setPresetChecked(page, presetId, true);
}

// Only add the custom Discovery URL when google_tasks is missing
// (re-adding would just produce a "skipped" row, but it is state this
// test would then wrongly clean up as its own).
if (customMissing) {
const customField = page.getByPlaceholder(
"https://www.googleapis.com/discovery/v1/apis/<service>/<version>/rest",
);
await customField.fill(customDiscoveryUrl);
await customField.press("Enter");
await page.getByText(customDiscoveryUrl).waitFor();
}

await page.getByTestId("google-add-submit").click();
const results = page.getByTestId("google-add-results");
await results.waitFor({ timeout: 120_000 });

// Each submitted entry resolves to a row that is either freshly
// "added" or "skipped" (raced with another writer) - both leave the
// integration present, which is all the grid needs. The custom Tasks
// URL reports under its derived slug.
const expectedRowIds = [...missingPresetIds, ...(customMissing ? ["google_tasks"] : [])];
for (const rowId of expectedRowIds) {
const row = page.getByTestId(`add-result-row-${rowId}`);
await row.waitFor({ timeout: 120_000 });
expect(["added", "skipped"], `${rowId} present (added or already existed)`).toContain(
await row.getAttribute("data-state"),
);
}
},
);

await step("The four Google integrations sit under one Google umbrella", async () => {
await page.goto("/integrations", { waitUntil: "networkidle" });
const main = page.getByRole("main");

// Exactly one provider umbrella, and it is the Google one.
const group = main.getByTestId("integration-group-google");
await group.waitFor({ timeout: 20_000 });
expect(await main.getByTestId("integration-group-google").count()).toBe(1);

// The umbrella header carries the provider name.
await group
.getByRole("button", { name: /Google/ })
.first()
.waitFor({ state: "visible", timeout: 20_000 });

// Every per-service integration (including the custom one) is an entry
// INSIDE the Google umbrella, not a flat sibling.
for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_tasks"]) {
const entry = group.getByTestId(`integration-entry-${slug}`);
await entry.waitFor({ timeout: 20_000 });
expect(await group.getByTestId(`integration-entry-${slug}`).count(), slug).toBe(1);
}
});

await step("A non-family integration (executor) stays outside every group", async () => {
const main = page.getByRole("main");
const executor = main.getByTestId("integration-entry-executor");
await executor.waitFor({ timeout: 20_000 });

// The built-in executor source exists in the catalog...
expect(await main.getByTestId("integration-entry-executor").count()).toBe(1);
// ...but not nested within the Google provider umbrella.
const group = main.getByTestId("integration-group-google");
expect(
await group.getByTestId("integration-entry-executor").count(),
"executor is not grouped under Google",
).toBe(0);
});

await step("Per-service integrations render distinct product icons", async () => {
const group = page.getByRole("main").getByTestId("integration-group-google");
const iconSrc = async (slug: string): Promise<string | null> =>
group.getByTestId(`integration-entry-${slug}`).locator("img").first().getAttribute("src");

const calendar = await iconSrc("google_calendar");
const gmail = await iconSrc("google_gmail");
const drive = await iconSrc("google_drive");

// Each service resolves to its own glyph URL (assert on src, not pixels).
expect(calendar, "calendar has an icon").toBeTruthy();
expect(gmail, "gmail has an icon").toBeTruthy();
expect(drive, "drive has an icon").toBeTruthy();
expect(
new Set([calendar, gmail, drive]).size,
"the three per-service icons are all distinct, not one repeated provider logo",
).toBe(3);
});
});

yield* session.pipe(
Effect.ensuring(
// Remove ONLY the integrations this test created (never pre-existing
// ones): on selfhost the next scenario acts as the same admin, and a
// leaked google_tasks would show up as an unexpected "skipped" row in
// the google-per-service spec's results.
Effect.forEach(
createdSlugs,
(slug) =>
api.integrations
.remove({ params: { slug: IntegrationSlug.make(slug) } })
.pipe(Effect.ignore),
{ discard: true },
),
),
);
}),
);
8 changes: 8 additions & 0 deletions packages/core/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,14 @@ export interface IntegrationPlugin {
readonly accountHandoff?: IntegrationAccountHandoff | null;
}>;
readonly presets?: readonly IntegrationPreset[];
/** Per-service icon overrides for provider plugins that fan out into several
* per-service integrations (e.g. Google → google_calendar, google_gmail).
* Keyed by the integration slug the fan-out registers; the catalog favicon
* resolver consults this before falling back to a URL-derived favicon, so a
* Gmail integration shows the Gmail glyph rather than a generic Google logo.
* Kept separate from `presets` so the connect picker's popular-integration
* list is not affected. */
readonly serviceIcons?: readonly { readonly slug: string; readonly icon: string }[];
/** Trigger early download of the plugin's lazy component chunks (add/edit/etc.).
* Call from the host on intent (hover/focus) so the chunks land before the
* user navigates into the add page. Idempotent. */
Expand Down
14 changes: 13 additions & 1 deletion packages/plugins/google/src/react/source-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { lazy } from "react";
import type { IntegrationPlugin } from "@executor-js/sdk/client";
import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";
import {
googleOpenApiBundlePreset,
googleOpenApiPresets,
googlePhotosOpenApiBundlePreset,
googleServiceSlug,
} from "../sdk/presets";

const importAdd = () => import("./AddGoogleSource");
const importAccounts = () => import("./GoogleAccountsPanel");

// Each per-service integration (google_calendar, google_gmail, …) is keyed by
// the slug the fan-out registers, mapped to the preset's own product glyph.
const googleServiceIcons = googleOpenApiPresets.flatMap((preset) =>
preset.icon ? [{ slug: googleServiceSlug(preset.id), icon: preset.icon }] : [],
);

export const googleIntegrationPlugin: IntegrationPlugin = {
key: "google",
label: "Google",
add: lazy(importAdd),
accounts: lazy(importAccounts),
presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],
serviceIcons: googleServiceIcons,
preload: () => {
void importAdd();
void importAccounts();
Expand Down
13 changes: 12 additions & 1 deletion packages/plugins/microsoft/src/react/source-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import { lazy } from "react";
import type { IntegrationPlugin } from "@executor-js/sdk/client";
import { microsoftGraphPreset } from "../sdk/presets";
import {
microsoftGraphPreset,
microsoftGraphScopePresets,
microsoftServiceSlug,
} from "../sdk/presets";

const importAdd = () => import("./AddMicrosoftSource");
const importAccounts = () => import("./MicrosoftAccountsPanel");

// Each per-workload integration (microsoft_mail, microsoft_calendar, …) is
// keyed by the slug the fan-out registers, mapped to the workload's own glyph.
const microsoftServiceIcons = microsoftGraphScopePresets.flatMap((preset) =>
preset.icon ? [{ slug: microsoftServiceSlug(preset.id), icon: preset.icon }] : [],
);

export const microsoftIntegrationPlugin: IntegrationPlugin = {
key: "microsoft",
label: "Microsoft",
add: lazy(importAdd),
accounts: lazy(importAccounts),
presets: [microsoftGraphPreset],
serviceIcons: microsoftServiceIcons,
preload: () => {
void importAdd();
void importAccounts();
Expand Down
62 changes: 62 additions & 0 deletions packages/react/src/components/integration-favicon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,68 @@ describe("IntegrationFavicon", () => {
expect(integrationFaviconSrc({ icon, sourceId: slug, url, size: 16 })).toBe("/favicon-32.png");
});

it("prefers a plugin's per-service icon (keyed by slug) over the preset cascade", () => {
// A provider that fans out into per-service integrations declares a
// slug->icon map; a Gmail integration resolves to the Gmail glyph even
// though every Google service shares the same displayUrl host.
expect(
integrationPresetIconUrl(
{
id: "google_gmail",
kind: "google",
name: "Gmail",
url: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
},
[
{
key: "google",
label: "Google",
add: () => null,
edit: () => null,
presets: [
{ id: "google", name: "Google", summary: "Bundle.", icon: "https://x/g.svg" },
],
serviceIcons: [
{ slug: "google_gmail", icon: "https://x/gmail.png" },
{ slug: "google_calendar", icon: "https://x/calendar.svg" },
],
},
],
),
).toBe("https://x/gmail.png");
});

it("falls through to the preset cascade when no per-service icon matches the slug", () => {
expect(
integrationPresetIconUrl(
{
id: "google_sheets",
kind: "google",
name: "Google Sheets",
url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
},
[
{
key: "google",
label: "Google",
add: () => null,
edit: () => null,
presets: [
{
id: "google-sheets",
name: "Google Sheets",
summary: "Spreadsheets.",
url: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
icon: "https://x/sheets.svg",
},
],
serviceIcons: [{ slug: "google_gmail", icon: "https://x/gmail.png" }],
},
],
),
).toBe("https://x/sheets.svg");
});

it("finds preset icons from a source URL", () => {
expect(
integrationPresetIconUrl(
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/components/integration-favicon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ export function integrationPresetIconUrl(
): string | null {
const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
const plugin = integrationPlugins.find((p) => p.key === pluginKey);

// Per-service integrations (google_calendar, microsoft_mail, …) carry their
// own product glyph, keyed by the exact slug the fan-out registered. This is
// the most specific match, so it wins over the URL/token preset cascade below
// (a provider's per-service integrations often share one displayUrl).
const serviceIcon = plugin?.serviceIcons?.find((entry) => entry.slug === source.id);
if (serviceIcon) return serviceIcon.icon;

const presets = plugin?.presets ?? [];
const sourceUrl = normalizeUrl(source.url);
const sourceGoogleService = googleApiServiceFromUrl(source.url);
Expand Down
Loading
Loading