Skip to content
Merged
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
13 changes: 10 additions & 3 deletions web_ui/e2e/boot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ test("boots against the real backend and renders the canvas shell", async ({ pag
await expect(page.getByTestId("scene-canvas")).toBeVisible();
await expect(page.locator(".react-flow__viewport")).toBeVisible();

// The connection badge (connectionBadge.ts) reporting the real WS
// handshake's outcome, not just "the page loaded".
await expect(page.locator(".app-conn-open")).toHaveText("connected");
// The real WS handshake's outcome (App.tsx's data-connection-status),
// not just "the page loaded". gotoApp already waited on this exact
// attribute to reach "open" before returning, so this is a direct
// re-assertion of that same state rather than a race - the app bar and
// canvas checks above ran after that wait, and a WS drop between then and
// now is exactly the kind of regression this line exists to catch.
// connectionBadge.ts's own label text is NOT asserted here any more: it
// now renders only for a degraded connection (App.tsx), so "connected"
// has no visible text to check on the happy path this test exercises.
await expect(page.locator('.app-shell[data-connection-status="open"]')).toBeAttached();

// A fresh session has zero nodes - SceneCanvas.tsx's own empty-state hint
// is the honest "nothing broken, genuinely nothing here yet" signal for
Expand Down
23 changes: 14 additions & 9 deletions web_ui/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ import { expect, type Page } from "@playwright/test";
*
* Two things every spec would otherwise have to repeat:
*
* 1. Wait for the REAL WS round-trip to complete (App.tsx's connection
* badge, `.app-conn-<status>`) before touching anything - a fresh
* `page.goto("/")` returns as soon as the SPA shell's static HTML/JS
* loads, well before the WsTransport handshake against the real backend
* (tests_e2e/run_backend.py) has actually completed.
* 1. Wait for the REAL WS round-trip to complete (App.tsx's
* `data-connection-status` attribute on `.app-shell`) before touching
* anything - a fresh `page.goto("/")` returns as soon as the SPA shell's
* static HTML/JS loads, well before the WsTransport handshake against the
* real backend (tests_e2e/run_backend.py) has actually completed. This
* used to wait on the visible connection badge (`.app-conn-open`), which
* stopped being a reliable signal once that badge became exception-only
* (App.tsx renders nothing there for a healthy connection by design) -
* the attribute is the same underlying signal without depending on
* whatever the topbar currently chooses to show for it.
* 2. Dismiss the first-run onboarding wizard (chrome/OnboardingDialog.tsx).
* Every E2E run boots against a BRAND NEW settings_state_file (see that
* script's own docstring on why - full isolation from a real user's
Expand Down Expand Up @@ -38,13 +43,13 @@ import { expect, type Page } from "@playwright/test";
*/
export async function gotoApp(page: Page): Promise<void> {
await page.goto("/");
await expect(page.locator(".app-conn-open")).toBeVisible();
await expect(page.locator('.app-shell[data-connection-status="open"]')).toBeAttached();

// waitFor (not isVisible(), which resolves immediately either way) is
// deliberate: the app-settings snapshot that decides whether onboarding
// auto-opens arrives asynchronously over the SAME WS connection
// app-conn-open just confirmed, so it can genuinely still be in flight at
// this exact line. A bare isVisible() check here would race it - "not
// auto-opens arrives asynchronously over the SAME WS connection the
// attribute above just confirmed, so it can genuinely still be in flight
// at this exact line. A bare isVisible() check here would race it - "not
// visible yet" and "never opening" look identical at a single instant,
// and picking the wrong one would leave the dialog to pop up mid-test
// instead of being dismissed up front.
Expand Down
48 changes: 43 additions & 5 deletions web_ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,18 @@ function App() {
<OverlayProvider>
<ReactFlowProvider>
<GlobalShortcuts store={sceneStore} />
<div className="app-shell">
{/* data-connection-status: the real WS connection state, always
present regardless of whether the topbar renders any visual
indicator for it (see .app-topbar-status below - a healthy
connection now renders nothing there by design). The E2E suite's
shared boot helper (e2e/helpers.ts) waits on this attribute to
know the real backend round-trip has completed before touching
anything, which it can no longer do by waiting for a badge that
is often absent on purpose. Not a UI affordance - never styled,
never meant to be seen - so this stays a plain attribute rather
than a class, the same "invisible hook, not a rendered element"
posture as aria-live regions elsewhere in this file. */}
<div className="app-shell" data-connection-status={status}>
{/* ADR-012 stage 12.3: the very first focusable element in the
page, per the standard skip-link convention - invisible until
it itself receives focus (Tab from anywhere before the canvas
Expand All @@ -400,12 +411,39 @@ function App() {
<div aria-live="polite" role="status" className="visually-hidden">
{announcement}
</div>
{/* Three declared grid tracks - brand, toolbar, status - so the
regions of a permanently-visible bar have fixed homes and
cannot encroach on one another. See .app-topbar in styles.css
and AppBar.tsx's own layout contract. */}
<header className="app-topbar">
<span className="app-title">Graphlink</span>
<div className="app-topbar-brand">
<svg aria-hidden="true" viewBox="0 0 24 24" className="app-brand-mark">
<circle cx="6" cy="7" r="2.6" />
<circle cx="18" cy="6" r="2.6" />
<circle cx="12" cy="17.5" r="2.6" />
<path d="M7.6 8.9 10.8 15M16.6 8.2 13.3 15M8.5 6.6h6.9" />
</svg>
<span className="app-title">Graphlink</span>
</div>
<AppBar store={sceneStore} />
<span className={`app-conn app-conn-${status}`} title={`backend ${system.backendVersion ?? ""}`}>
{connectionBadgeLabel(status)}
</span>
{/* Exception-only: a healthy connection shows nothing at all.
A permanent "connected" badge reports the expected state on
every frame and carries no information. The degraded states
do carry information - "reconnecting" is the app's own
answer to "why did my click do nothing", since intents are
queued or refused while it shows (see connectionBadge.ts) -
so those still surface, and only those. */}
<div className="app-topbar-status">
{status !== "open" && (
<span
className={`app-conn app-conn-${status}`}
title={`backend ${system.backendVersion ?? ""}`}
>
<span className="app-conn-dot" aria-hidden="true" />
{connectionBadgeLabel(status)}
</span>
)}
</div>
</header>

<main className="app-canvas-region">
Expand Down
34 changes: 22 additions & 12 deletions web_ui/src/app/chrome/AppBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,29 @@ describe("AppBar", () => {
await user.click(screen.getByRole("button", { name: "More toolbar actions" }));
const menu = screen.getByRole("dialog");

// Tiers live on the GROUP wrapper, not the individual button: the
// bar collapses whole clusters so related actions stay together at
// every width instead of leaving fragments behind (see AppBar.tsx).
// The contract this pins is unchanged though - an inline action and
// its overflow duplicate must always collapse at the same tier.
const pairs: [string, string][] = [
["Export PNG", "1"],
["Pins", "2"],
["Undo", "1"],
["Redo", "1"],
["Zoom In", "1"],
["Zoom Out", "1"],
["Reset", "1"],
["Fit All", "1"],
["Organize", "2"],
["View", "2"],
["Plugins", "2"],
["Zoom In", "3"],
["Zoom Out", "3"],
["Reset", "3"],
["Fit All", "3"],
["About", "1"],
["Help", "1"],
["Diagnostics", "1"],
["Pins", "2"],
["Export PNG", "2"],
["View", "3"],
["Plugins", "3"],
["Global Search", "4"],
["Knowledge", "4"],
["Builder", "4"],
["Diagnostics", "4"],
["Help", "4"],
["About", "4"],
];
for (const [label, tier] of pairs) {
// Every duplicated pair shares its exact label except Plugins (the
Expand All @@ -222,7 +232,7 @@ describe("AppBar", () => {
const matches = screen.getAllByRole("button", { name: new RegExp(label) });
const inline = matches.find((el) => !menu.contains(el));
const overflowItem = matches.find((el) => menu.contains(el));
expect(inline).toHaveAttribute("data-tier", tier);
expect(inline?.closest(".appbar-group")).toHaveAttribute("data-tier", tier);
expect(overflowItem).toHaveAttribute("data-tier", tier);
}

Expand Down
Loading
Loading