Skip to content

Commit a82634c

Browse files
committed
Add e2e specs for the per-service integration fan-out
Cover the Google product and Microsoft workload pickers end to end: check several presets, submit, and assert each fans out to its own integration with added/skipped result rows, a per-service detail page, and separate list entries. The add paths fetch real provider specs (Google Discovery, msgraph-metadata), so these run against the live specs rather than an emulator. Also stub the Google per-service OAuth scenario as a documented skip: the preset add path hardcodes Discovery and OAuth endpoints to real Google with no emulator override, so it cannot drive an auto-approving consent flow yet.
1 parent 4e220ef commit a82634c

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Google per-service picker fan-out: the add-Google flow lets a user check
2+
// several Google products and submit them in one action; each checked preset
3+
// becomes its OWN integration (google_calendar, google_gmail, google_drive),
4+
// not one bundled "google" source. This drives the whole browser path:
5+
//
6+
// 1. Open /integrations/add/google (the Google-owned add flow).
7+
// 2. Clear the featured defaults, then check exactly Calendar + Gmail + Drive.
8+
// 3. Submit via `google-add-submit`. The flow fetches each preset's real
9+
// Google Discovery document (www.googleapis.com) and registers one
10+
// integration per product.
11+
// 4. The result panel shows three `add-result-row-*` rows, each data-state
12+
// "added", each with an Open link.
13+
// 5. Follow one Open link: the integration detail page shows the per-service
14+
// name ("Google Calendar"), proving the fan-out kept preset identities.
15+
// 6. Back on the integrations list, all three separate integrations exist
16+
// (asserted by their distinct slugs in the list UI).
17+
// 7. Re-open the add flow, check Calendar again, submit: its row now reports
18+
// data-state "skipped" (the integration already exists), proving the
19+
// idempotent add path.
20+
//
21+
// OUTBOUND DISCOVERY: unlike the emulator-backed scenarios, this exercises the
22+
// real add path. The Google preset add flow HARDCODES the Discovery host
23+
// (`normalizeGoogleDiscoveryUrl` only accepts googleapis.com HTTPS Discovery
24+
// endpoints) and the OAuth authorize/token URLs (accounts.google.com /
25+
// oauth2.googleapis.com). There is no baseUrl or custom-URL override that
26+
// redirects a preset's Discovery fetch at the emulator, so this scenario adds
27+
// the integrations against real Google Discovery documents (read-only, no
28+
// credentials, no OAuth). The cloud e2e runners (and CI's blacksmith runners)
29+
// have outbound internet, so the fetch resolves; the scenario asserts only on
30+
// the product's own catalog state, never on Google account data.
31+
import { expect } from "@effect/vitest";
32+
import { Effect } from "effect";
33+
34+
import { scenario } from "../src/scenario";
35+
import { Browser, Target } from "../src/services";
36+
import { setPresetChecked } from "./support/picker";
37+
38+
scenario(
39+
"Google · the per-service picker fans out to separate integrations and skips existing ones",
40+
{ timeout: 240_000 },
41+
Effect.gen(function* () {
42+
const target = yield* Target;
43+
const browser = yield* Browser;
44+
const identity = yield* target.newIdentity();
45+
46+
yield* browser.session(identity, async ({ page, step }) => {
47+
await step("Open the Google add flow", async () => {
48+
await page.goto("/integrations/add/google", { waitUntil: "domcontentloaded" });
49+
await page.getByRole("heading", { name: "Add Google integration" }).waitFor();
50+
await page.getByText("Customize your Google connection").waitFor();
51+
// The picker mounts with the featured defaults checked.
52+
await page.getByTestId("preset-checkbox-google-calendar").waitFor();
53+
});
54+
55+
await step("Clear the defaults, then check exactly Calendar + Gmail + Drive", async () => {
56+
// Clear every featured default, then select only the three under test.
57+
const featuredDefaults = [
58+
"google-calendar",
59+
"google-gmail",
60+
"google-sheets",
61+
"google-drive",
62+
"google-docs",
63+
];
64+
for (const presetId of featuredDefaults) {
65+
await setPresetChecked(page, presetId, false);
66+
}
67+
68+
for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) {
69+
await setPresetChecked(page, presetId, true);
70+
}
71+
// Everything else is unchecked: this is a scoped three-product selection.
72+
expect(await page.getByTestId("preset-checkbox-google-sheets").isChecked()).toBe(false);
73+
expect(await page.getByTestId("preset-checkbox-google-docs").isChecked()).toBe(false);
74+
});
75+
76+
await step("Submit the fan-out and see three added integrations", async () => {
77+
await page.getByTestId("google-add-submit").click();
78+
// The result panel appears once every product is registered. Discovery
79+
// fetch + spec compile per product; allow generous time.
80+
const results = page.getByTestId("google-add-results");
81+
await results.waitFor({ timeout: 120_000 });
82+
83+
for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) {
84+
const row = page.getByTestId(`add-result-row-${presetId}`);
85+
await row.waitFor({ timeout: 120_000 });
86+
expect(
87+
await row.getAttribute("data-state"),
88+
`${presetId} added as its own integration`,
89+
).toBe("added");
90+
// Each added row links out to its own integration page.
91+
await row.getByRole("link", { name: "Open" }).waitFor();
92+
}
93+
});
94+
95+
await step("Open one product: its integration page shows the per-service name", async () => {
96+
await page
97+
.getByTestId("add-result-row-google-calendar")
98+
.getByRole("link", { name: "Open" })
99+
.click();
100+
await page.waitForURL(/\/integrations\/google_calendar\b/);
101+
// The detail header renders the per-service name, not a generic "Google".
102+
await page.getByText("Google Calendar").first().waitFor({ timeout: 20_000 });
103+
});
104+
105+
await step("The integrations list has three separate Google integrations", async () => {
106+
await page.goto("/integrations", { waitUntil: "networkidle" });
107+
// Each fanned-out integration is its own list entry, keyed by its slug
108+
// (the list renders the slug as each entry's description).
109+
for (const slug of ["google_calendar", "google_gmail", "google_drive"]) {
110+
await page.getByText(slug, { exact: true }).first().waitFor({ timeout: 20_000 });
111+
}
112+
});
113+
114+
await step("Re-adding an existing product reports it as skipped", async () => {
115+
await page.goto("/integrations/add/google", { waitUntil: "domcontentloaded" });
116+
await page.getByText("Customize your Google connection").waitFor();
117+
118+
// Clear the featured defaults so only Calendar (already added) is checked.
119+
for (const presetId of ["google-gmail", "google-sheets", "google-drive", "google-docs"]) {
120+
await setPresetChecked(page, presetId, false);
121+
}
122+
await setPresetChecked(page, "google-calendar", true);
123+
124+
await page.getByTestId("google-add-submit").click();
125+
const row = page.getByTestId("add-result-row-google-calendar");
126+
await row.waitFor({ timeout: 120_000 });
127+
expect(
128+
await row.getAttribute("data-state"),
129+
"an already-added product is skipped, not re-added or failed",
130+
).toBe("skipped");
131+
});
132+
});
133+
}),
134+
);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Google per-service OAuth connect + auto-naming + ledger (the intended shape):
2+
// boot the Google emulator, add ONE per-service integration pointed at the
3+
// emulator's Discovery + OAuth, drive the browser add-account OAuth flow
4+
// (auto-approving emulator consent), then assert the connection was created,
5+
// its identityLabel was auto-filled from the emulator account's email (the
6+
// userinfo health-check identity branch), and a tool call landed in the
7+
// emulator ledger with the right auth.
8+
//
9+
// BLOCKED (pre-existing, not this PR): the Google per-service add path hardcodes
10+
// its upstreams to real Google, with NO override toward an emulator:
11+
//
12+
// * Discovery host: `google.addServices` resolves each preset through
13+
// `googleBundleUrlsWithIdentity` → `normalizeGoogleDiscoveryUrl`, which only
14+
// accepts `https://www.googleapis.com/...` (or `<svc>.googleapis.com`) HTTPS
15+
// Discovery endpoints. The emulator serves Discovery at
16+
// `http://127.0.0.1:<port>/discovery/v1/apis/...`, which the normalizer
17+
// rejects. The `baseUrl` payload field is stored as integration config only;
18+
// it does NOT redirect the Discovery fetch.
19+
// (packages/plugins/google/src/sdk/discovery.ts, sdk/plugin.ts)
20+
//
21+
// * OAuth authorize/token: the converted spec bakes
22+
// `authorizationUrl = https://accounts.google.com/o/oauth2/v2/auth` and
23+
// `tokenUrl = https://oauth2.googleapis.com/token` into the integration's
24+
// `googleOAuth2` template (googleOauthTemplate in discovery.ts). There is no
25+
// per-integration override to point these at the emulator's `/o/oauth2/v2/auth`
26+
// + `/token`, so a browser consent can't be redirected at the emulator's
27+
// auto-approving pages the way the WorkOS/Microsoft emulator flows are.
28+
//
29+
// * The Google emulator's OAuth credential mint returns only client_id /
30+
// client_secret (no authorization_url / token_url), unlike the Microsoft
31+
// emulator, so even the microsoft-emulator.test.ts trick of creating an
32+
// oauth client with emulator URLs has nothing to point at.
33+
//
34+
// Making this real needs the add path to accept a per-integration Discovery +
35+
// OAuth endpoint override (emulator base), or a Google emulator that serves a
36+
// googleapis.com-shaped Discovery host the normalizer accepts. Tracked
37+
// separately. The picker fan-out itself IS covered end-to-end (against real
38+
// Google Discovery, read-only) in google-per-service-add-ui.test.ts.
39+
import { Effect } from "effect";
40+
41+
import { scenario } from "../src/scenario";
42+
import { Target } from "../src/services";
43+
44+
scenario(
45+
"Google · per-service OAuth against the emulator auto-names the connection and lands in the ledger",
46+
{
47+
skip: "google.addServices hardcodes Discovery (googleapis.com) and OAuth (accounts.google.com/oauth2.googleapis.com); no per-integration emulator override, and the Google emulator mint returns no authorization_url/token_url to point a client at",
48+
timeout: 180_000,
49+
},
50+
Effect.gen(function* () {
51+
yield* Target;
52+
}),
53+
);
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Microsoft per-workload picker fan-out: the mirror of the Google per-service
2+
// spec. The add-Microsoft flow lets a user check several Graph workloads and
3+
// submit them in one action; each checked workload becomes its OWN integration
4+
// (microsoft_mail, microsoft_calendar), each a scope-filtered slice of the
5+
// Graph, not one bundled "microsoft_graph" source. This drives the whole
6+
// browser path: clear the featured defaults, check exactly Mail + Calendar,
7+
// submit, assert two `add-result-row-*` rows added, follow one Open link to a
8+
// per-workload integration page, confirm both slugs in the list, then re-add
9+
// Mail and see it reported skipped.
10+
//
11+
// OUTBOUND SPEC (same shape as the Google spec, scoped-out reasons below):
12+
// `microsoft.addWorkloads` fetches the real Microsoft Graph OpenAPI document
13+
// (the 37MB msgraph-metadata YAML on raw.githubusercontent.com) and streams it
14+
// through the block-YAML profile compiler per workload; `specUrl` must point at
15+
// the trusted Microsoft Graph source (graph.microsoft.com / msgraph-metadata),
16+
// and the emulator's small custom Graph spec is NOT in that streaming profile
17+
// (see microsoft-emulator.test.ts's skip). So, like the Google add spec, this
18+
// exercises the REAL add path against the canonical Graph spec (read-only, no
19+
// credentials, no OAuth); it asserts only on the product's own catalog state.
20+
//
21+
// Because the 37MB spec is fetched and stream-compiled once per workload at
22+
// {concurrency: 1}, the add step is slow; the timeouts below are generous.
23+
import { expect } from "@effect/vitest";
24+
import { Effect } from "effect";
25+
26+
import { scenario } from "../src/scenario";
27+
import { Browser, Target } from "../src/services";
28+
import { setPresetChecked } from "./support/picker";
29+
30+
scenario(
31+
"Microsoft · the per-workload picker fans out to separate integrations and skips existing ones",
32+
{ timeout: 420_000 },
33+
Effect.gen(function* () {
34+
const target = yield* Target;
35+
const browser = yield* Browser;
36+
const identity = yield* target.newIdentity();
37+
38+
yield* browser.session(identity, async ({ page, step }) => {
39+
await step("Open the Microsoft add flow", async () => {
40+
await page.goto("/integrations/add/microsoft", { waitUntil: "domcontentloaded" });
41+
await page.getByRole("heading", { name: "Add Microsoft integration" }).waitFor();
42+
await page.getByText("Customize Microsoft Graph").waitFor();
43+
await page.getByTestId("preset-checkbox-mail").waitFor();
44+
});
45+
46+
await step("Clear the defaults, then check exactly Mail + Calendar", async () => {
47+
// The featured defaults (profile, mail, calendar, contacts, tasks, files)
48+
// start checked; clear them, then select only the two under test.
49+
for (const presetId of ["profile", "mail", "calendar", "contacts", "tasks", "files"]) {
50+
await setPresetChecked(page, presetId, false);
51+
}
52+
for (const presetId of ["mail", "calendar"]) {
53+
await setPresetChecked(page, presetId, true);
54+
}
55+
expect(await page.getByTestId("preset-checkbox-contacts").isChecked()).toBe(false);
56+
expect(await page.getByTestId("preset-checkbox-files").isChecked()).toBe(false);
57+
});
58+
59+
await step("Submit the fan-out and see two added integrations", async () => {
60+
await page.getByTestId("microsoft-add-submit").click();
61+
// Each workload fetches + stream-compiles the 37MB Graph spec; allow a
62+
// wide window for the result panel to populate.
63+
await page.getByTestId("microsoft-add-results").waitFor({ timeout: 300_000 });
64+
65+
for (const presetId of ["mail", "calendar"]) {
66+
const row = page.getByTestId(`add-result-row-${presetId}`);
67+
await row.waitFor({ timeout: 300_000 });
68+
expect(
69+
await row.getAttribute("data-state"),
70+
`${presetId} added as its own integration`,
71+
).toBe("added");
72+
await row.getByRole("link", { name: "Open" }).waitFor();
73+
}
74+
});
75+
76+
await step(
77+
"Open one workload: its integration page shows the per-workload name",
78+
async () => {
79+
await page.getByTestId("add-result-row-mail").getByRole("link", { name: "Open" }).click();
80+
await page.waitForURL(/\/integrations\/microsoft_mail\b/);
81+
await page.getByText("Outlook Mail").first().waitFor({ timeout: 20_000 });
82+
},
83+
);
84+
85+
await step("The integrations list has two separate Microsoft integrations", async () => {
86+
await page.goto("/integrations", { waitUntil: "networkidle" });
87+
for (const slug of ["microsoft_mail", "microsoft_calendar"]) {
88+
await page.getByText(slug, { exact: true }).first().waitFor({ timeout: 20_000 });
89+
}
90+
});
91+
92+
await step("Re-adding an existing workload reports it as skipped", async () => {
93+
await page.goto("/integrations/add/microsoft", { waitUntil: "domcontentloaded" });
94+
await page.getByText("Customize Microsoft Graph").waitFor();
95+
96+
for (const presetId of ["profile", "mail", "calendar", "contacts", "tasks", "files"]) {
97+
await setPresetChecked(page, presetId, false);
98+
}
99+
await setPresetChecked(page, "mail", true);
100+
101+
await page.getByTestId("microsoft-add-submit").click();
102+
const row = page.getByTestId("add-result-row-mail");
103+
await row.waitFor({ timeout: 300_000 });
104+
expect(
105+
await row.getAttribute("data-state"),
106+
"an already-added workload is skipped, not re-added or failed",
107+
).toBe("skipped");
108+
});
109+
});
110+
}),
111+
);

e2e/scenarios/support/picker.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Shared helper for the provider fan-out pickers (Google products, Microsoft
2+
// workloads). Each preset checkbox is a Radix `<button role="checkbox">` wrapped
3+
// in a `<label>` (FieldLabel). A single programmatic click can bubble to the
4+
// label and re-dispatch to the control, double-toggling it (net no change), so a
5+
// bare Playwright `check()`/`uncheck()` is racy here and errors with "Clicking
6+
// the checkbox did not change its state". Drive the box to a known state by
7+
// reading `aria-checked` and clicking only while it disagrees, polling until it
8+
// settles.
9+
import { expect } from "@effect/vitest";
10+
import type { Page } from "playwright";
11+
12+
export const setPresetChecked = async (
13+
page: Page,
14+
presetId: string,
15+
checked: boolean,
16+
): Promise<void> => {
17+
const box = page.getByTestId(`preset-checkbox-${presetId}`);
18+
await box.waitFor();
19+
await expect
20+
.poll(
21+
async () => {
22+
if ((await box.getAttribute("aria-checked")) === String(checked)) return String(checked);
23+
await box.click();
24+
return box.getAttribute("aria-checked");
25+
},
26+
{ timeout: 10_000, interval: 200 },
27+
)
28+
.toBe(String(checked));
29+
};

0 commit comments

Comments
 (0)