Skip to content

Commit 28e964f

Browse files
committed
Hold one loading surface when opening an integration
1 parent d27e673 commit 28e964f

4 files changed

Lines changed: 558 additions & 137 deletions

File tree

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// Opening an integration from the list shows ONE loading surface, not four.
2+
//
3+
// ## What went wrong
4+
//
5+
// Clicking an integration in the list walked the detail page through a run of
6+
// visually distinct placeholders, each alive for a few hundred milliseconds:
7+
//
8+
// 1. the header printed the raw URL slug (`postman-echo-9f2a`) and then
9+
// swapped it for the real name ("Postman Echo");
10+
// 2. the Accounts pane rendered the generic section with ZERO auth methods,
11+
// which is the same render as "this integration has no way to connect" —
12+
// so the dashed empty card briefly said "Ask a workspace admin to
13+
// configure an authentication method";
14+
// 3. a pulsing dot and the words "Loading accounts…" — a loading vocabulary
15+
// used nowhere else on the page;
16+
// 4. the real accounts content, in a taller box than any of the above.
17+
//
18+
// Every one of those is an internal boundary of ours — the catalog row request,
19+
// the plugin lookup that depends on it, the connections request — and none is a
20+
// fact the person clicking an integration has any use for. What they produced
21+
// was churn: several different things flashing in several different places, and
22+
// one of them stating something false about the integration.
23+
//
24+
// ## What is asserted, and why it is asserted this way
25+
//
26+
// Same method as the artifact loading-surface scenario, for the same reason:
27+
// the states in question were only ever on screen for a few hundred
28+
// milliseconds, so a screenshot at one moment would miss them and pass against
29+
// the old code too. The page is SAMPLED CONTINUOUSLY from before the click
30+
// until the detail page is live, and the assertion is over everything that was
31+
// ever on screen:
32+
//
33+
// - none of the placeholder texts ever appeared, and the raw slug was never
34+
// shown as the page title;
35+
// - the detail body's box never changed size, so nothing was laid out twice.
36+
//
37+
// Both directions are covered, because they fail differently: warm (a click
38+
// from the list, catalog already in the client's atom cache) and cold (a direct
39+
// URL in a fresh context, where nothing is cached and the window is widest).
40+
import { randomBytes } from "node:crypto";
41+
import { createServer } from "node:http";
42+
43+
import { expect } from "@effect/vitest";
44+
import { Effect, Predicate } from "effect";
45+
import type { Page } from "playwright";
46+
import { composePluginApi } from "@executor-js/api/server";
47+
import { AccountHttpApi } from "@executor-js/api";
48+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
49+
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
50+
51+
import { scenario } from "../src/scenario";
52+
import { Api, Browser, Target } from "../src/services";
53+
import { visit } from "../src/surfaces/browser";
54+
55+
const api = composePluginApi([openApiHttpPlugin()] as const);
56+
57+
const TEMPLATE = AuthTemplateSlug.make("apiKey");
58+
59+
/** A display name that shares no substring with the slug, so "the header showed
60+
* the slug" and "the header showed the name" are impossible to confuse. */
61+
const DISPLAY_NAME = "Postman Echo";
62+
63+
/** A two-operation spec — enough that the Tools tab has real content to lay out
64+
* once the page settles, which is what the steady-state box is measured at. */
65+
const echoSpec = (baseUrl: string): string =>
66+
JSON.stringify({
67+
openapi: "3.0.3",
68+
info: { title: DISPLAY_NAME, version: "1.0.0" },
69+
servers: [{ url: baseUrl }],
70+
paths: {
71+
"/me": {
72+
get: {
73+
operationId: "getMe",
74+
summary: "The current account",
75+
responses: { "200": { description: "ok" } },
76+
},
77+
},
78+
"/echo": {
79+
get: {
80+
operationId: "getEcho",
81+
summary: "Echo the request back",
82+
responses: { "200": { description: "ok" } },
83+
},
84+
},
85+
},
86+
});
87+
88+
/** A real node:http upstream on 127.0.0.1 so discovery and health probes have
89+
* something that answers. Closed by the scope's finalizer. */
90+
const serveEchoApi = Effect.acquireRelease(
91+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
92+
const server = createServer((request, response) => {
93+
response.writeHead(200, { "content-type": "application/json" });
94+
response.end(JSON.stringify({ ok: true, url: request.url }));
95+
});
96+
server.listen(0, "127.0.0.1", () => {
97+
const address = server.address();
98+
const port = typeof address === "object" && address ? address.port : 0;
99+
resume(
100+
Effect.succeed({
101+
url: `http://127.0.0.1:${port}`,
102+
close: () => {
103+
server.close();
104+
server.closeAllConnections();
105+
},
106+
}),
107+
);
108+
});
109+
}),
110+
(server) => Effect.sync(server.close),
111+
);
112+
113+
type Sample = {
114+
readonly text: string;
115+
readonly title: string | null;
116+
readonly body: {
117+
readonly w: number;
118+
readonly h: number;
119+
} | null;
120+
};
121+
122+
declare global {
123+
// eslint-disable-next-line no-var
124+
var __detailSamples: Array<Sample> | undefined;
125+
}
126+
127+
/**
128+
* Watch the console document continuously for the whole open.
129+
*
130+
* Installed as an init script so it is running before the first byte of the
131+
* page, and polls on an animation frame — fast enough that a placeholder
132+
* visible for even one paint is recorded. The header title and the detail
133+
* body's box are sampled alongside the text, because a placeholder that came
134+
* and went without changing any WORDS would still have moved the layout, and a
135+
* layout that jumps is the same defect wearing a different hat.
136+
*/
137+
const startSampling = async (page: Page): Promise<void> => {
138+
await page.addInitScript(() => {
139+
globalThis.__detailSamples = [];
140+
const sample = () => {
141+
const body = document.querySelector<HTMLElement>('[data-testid="integration-detail-body"]');
142+
const box = body?.getBoundingClientRect();
143+
const title = document.querySelector<HTMLElement>('[data-testid="integration-detail-title"]');
144+
globalThis.__detailSamples?.push({
145+
text: document.body?.innerText ?? "",
146+
title: title?.innerText ?? null,
147+
body: box ? { w: Math.round(box.width), h: Math.round(box.height) } : null,
148+
});
149+
requestAnimationFrame(sample);
150+
};
151+
sample();
152+
});
153+
};
154+
155+
const readSamples = (page: Page): Promise<ReadonlyArray<Sample>> =>
156+
page.evaluate(() => globalThis.__detailSamples ?? []);
157+
158+
const resetSamples = (page: Page): Promise<void> =>
159+
page.evaluate(() => {
160+
globalThis.__detailSamples = [];
161+
});
162+
163+
/**
164+
* Assert the whole open was one surface.
165+
*
166+
* Three independent properties over the same recording — no placeholder words,
167+
* no raw slug in the title, and a body box that never changed — because any one
168+
* alone would let the churn back in through another door.
169+
*/
170+
const expectSingleLoadingSurface = (
171+
samples: ReadonlyArray<Sample>,
172+
slug: string,
173+
label: string,
174+
): void => {
175+
expect(samples.length, `${label}: the sampler actually ran`).toBeGreaterThan(3);
176+
177+
// The exact strings the superseded placeholders rendered. Named literally
178+
// rather than by testid: the point is that these WORDS are gone from the
179+
// experience, and a rename that kept the churn should not pass.
180+
const forbidden = [
181+
"Loading accounts",
182+
// The empty-state copy for "this integration declares no auth method". It
183+
// is a true sentence for such an integration and a false one here, so it
184+
// must never appear for an integration that has connections.
185+
"Ask a workspace admin to configure an authentication method",
186+
"No connections yet",
187+
] as const;
188+
189+
for (const text of forbidden) {
190+
const hit = samples.findIndex((entry) => entry.text.includes(text));
191+
expect(
192+
hit,
193+
`${label}: "${text}" was on screen at sample ${hit} of ${samples.length} — the open still walks through more than one loading state`,
194+
).toBe(-1);
195+
}
196+
197+
// The title is the integration's name or nothing at all — never the raw slug
198+
// from the URL, which is a machine identifier the reader did not ask to see.
199+
const slugTitle = samples.findIndex((entry) => entry.title?.includes(slug) === true);
200+
expect(
201+
slugTitle,
202+
`${label}: the header printed the raw slug "${slug}" at sample ${slugTitle} of ${samples.length} before the name arrived`,
203+
).toBe(-1);
204+
205+
// The body's geometry, over every frame in which a body existed at all. The
206+
// skeleton and the settled content share one box by construction, so a change
207+
// here means the content was laid out differently from the skeleton that held
208+
// its place.
209+
const boxes = samples.map((entry) => entry.body).filter(Predicate.isNotNull);
210+
expect(boxes.length, `${label}: the detail body was on screen at some point`).toBeGreaterThan(0);
211+
212+
const first = boxes[0];
213+
if (!first) return;
214+
for (const [index, box] of boxes.entries()) {
215+
// A pixel of tolerance for sub-pixel rounding as the scrollbar settles.
216+
expect(
217+
Math.abs(box.w - first.w) <= 1 && Math.abs(box.h - first.h) <= 1,
218+
`${label}: the detail body changed size mid-load at sample ${index} (${JSON.stringify(box)} vs ${JSON.stringify(first)}) — the content appeared in a different box than the skeleton held`,
219+
).toBe(true);
220+
}
221+
};
222+
223+
scenario(
224+
"Integrations · opening an integration shows one loading surface",
225+
{ timeout: 240_000 },
226+
Effect.gen(function* () {
227+
const target = yield* Target;
228+
const browser = yield* Browser;
229+
const { client: apiClient } = yield* Api;
230+
231+
const upstream = yield* serveEchoApi;
232+
const identity = yield* target.newIdentity();
233+
const client = yield* apiClient(api, identity);
234+
235+
const slug = IntegrationSlug.make(`postman-echo-${randomBytes(4).toString("hex")}`);
236+
237+
yield* client.openapi.addSpec({
238+
payload: {
239+
spec: { kind: "blob", value: echoSpec(upstream.url) },
240+
slug,
241+
baseUrl: upstream.url,
242+
authenticationTemplate: [
243+
{
244+
slug: "apiKey",
245+
type: "apiKey",
246+
headers: { authorization: ["Bearer ", { type: "variable", name: "token" }] },
247+
},
248+
],
249+
},
250+
});
251+
252+
// A real connection, so the settled Accounts pane is the POPULATED one —
253+
// the state whose height the loading surface has to hold open.
254+
yield* client.connections.create({
255+
payload: {
256+
owner: "org",
257+
name: ConnectionName.make("primary"),
258+
integration: slug,
259+
template: TEMPLATE,
260+
value: "tok_echo",
261+
},
262+
});
263+
264+
const accountClient = yield* apiClient(AccountHttpApi, identity);
265+
const me = yield* accountClient.account.me();
266+
const orgSlug = me.organization?.slug;
267+
const listPath = orgSlug ? `/${orgSlug}` : "/";
268+
const detailPath = orgSlug
269+
? `/${orgSlug}/integrations/${String(slug)}`
270+
: `/integrations/${String(slug)}`;
271+
272+
// ------------------------------------------------------------------
273+
// WARM: the journey a user actually takes — the list, then a click.
274+
// ------------------------------------------------------------------
275+
yield* browser.session(identity, async ({ page, step }) => {
276+
await step("Open the integrations list", async () => {
277+
await startSampling(page);
278+
await visit(page, `${target.baseUrl}${listPath}`);
279+
await page.getByTestId(`integration-entry-${String(slug)}`).waitFor({ timeout: 30_000 });
280+
// Discard everything from the list's own load: this scenario is about
281+
// the OPEN, and the list has a loading state of its own.
282+
await resetSamples(page);
283+
});
284+
285+
await step("Click through to the integration", async () => {
286+
await page.getByTestId(`integration-entry-${String(slug)}`).click();
287+
await page.getByTestId("connection-row-primary").waitFor({ timeout: 60_000 });
288+
});
289+
290+
await step("The whole open was one surface", async () => {
291+
expectSingleLoadingSurface(await readSamples(page), String(slug), "warm open");
292+
});
293+
294+
await step("The settled page is the real integration", async () => {
295+
await expect
296+
.poll(async () => await page.getByTestId("integration-detail-title").innerText(), {
297+
timeout: 10_000,
298+
message: "the header carries the integration's display name",
299+
})
300+
.toContain(DISPLAY_NAME);
301+
});
302+
});
303+
304+
// ------------------------------------------------------------------
305+
// COLD: the deep link, in a context that has never loaded the console.
306+
//
307+
// The harder case: nothing is cached, so the catalog row and the
308+
// connections both start from zero and the loading window is at its widest.
309+
// ------------------------------------------------------------------
310+
yield* browser.session(identity, async ({ page, step }) => {
311+
await step("Open the integration by URL, cold", async () => {
312+
await startSampling(page);
313+
await page.goto(`${target.baseUrl}${detailPath}`, { waitUntil: "commit" });
314+
await page.getByTestId("connection-row-primary").waitFor({ timeout: 60_000 });
315+
});
316+
317+
await step("The cold open was one surface too", async () => {
318+
expectSingleLoadingSurface(await readSamples(page), String(slug), "cold open");
319+
});
320+
});
321+
}),
322+
);

0 commit comments

Comments
 (0)