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
117 changes: 109 additions & 8 deletions frontend/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,84 @@ import { expect, test } from "@playwright/test";

test("renders the managed skills page", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Skills" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Skills", exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible();
await expect(page.getByPlaceholder("Search managed skills by name, description, or state")).toBeVisible();
await expect(page.getByLabel("Managed skills list")).toBeVisible();
await expect(page.getByRole("switch").first()).toBeVisible();
await expect(page.getByText("Shared Audit")).toBeVisible();
await expect(page.getByRole("link", { name: /Unmanaged/i })).toBeVisible();
await expect(page.getByRole("navigation", { name: "Skills views" }).getByRole("link", { name: /Unmanaged/i })).toBeVisible();
});

test("keeps managed skills scroll contained to the list surface on desktop", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByLabel("Managed skills list")).toBeVisible();

const metrics = await page.evaluate(() => {
const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
const chrome = document.querySelector(".skills-pane__chrome") as HTMLElement | null;
const content = document.querySelector(".skills-pane__content") as HTMLElement | null;
if (!scroller || !chrome) {
throw new Error("Skills pane scaffold was not rendered.");
}
if (content) {
content.style.minHeight = `${scroller.clientHeight + 640}px`;
}
const chromeTop = Math.round(chrome.getBoundingClientRect().top);
scroller.scrollTop = 320;
window.scrollTo(0, 240);

return {
windowScrollY: window.scrollY,
bodyScrollHeight: document.body.scrollHeight,
viewportHeight: window.innerHeight,
chromeTop,
chromeTopAfterScroll: Math.round(chrome.getBoundingClientRect().top),
scrollerClientHeight: scroller.clientHeight,
scrollerScrollHeight: scroller.scrollHeight,
scrollerScrollTop: scroller.scrollTop,
};
});

expect(metrics.windowScrollY).toBe(0);
expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight);
expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight);
expect(metrics.scrollerScrollTop).toBe(320);
expect(metrics.chromeTopAfterScroll).toBe(metrics.chromeTop);
});

test("keeps managed skills scroll contained to the list surface below the old breakpoint", async ({ page }) => {
await page.setViewportSize({ width: 1100, height: 900 });
await page.goto("/");
await expect(page.getByLabel("Managed skills list")).toBeVisible();

const metrics = await page.evaluate(() => {
const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
const content = document.querySelector(".skills-pane__content") as HTMLElement | null;
if (!scroller) {
throw new Error("Skills pane scroller was not rendered.");
}
if (content) {
content.style.minHeight = `${scroller.clientHeight + 520}px`;
}
scroller.scrollTop = 260;
window.scrollTo(0, 180);

return {
windowScrollY: window.scrollY,
bodyScrollHeight: document.body.scrollHeight,
viewportHeight: window.innerHeight,
scrollerClientHeight: scroller.clientHeight,
scrollerScrollHeight: scroller.scrollHeight,
scrollerScrollTop: scroller.scrollTop,
};
});

expect(metrics.windowScrollY).toBe(0);
expect(metrics.bodyScrollHeight).toBe(metrics.viewportHeight);
expect(metrics.scrollerScrollHeight).toBeGreaterThan(metrics.scrollerClientHeight);
expect(metrics.scrollerScrollTop).toBe(260);
});

test("renders the unmanaged intake page", async ({ page }) => {
Expand All @@ -20,19 +91,49 @@ test("renders the unmanaged intake page", async ({ page }) => {
await expect(page.getByRole("button", { name: "Bring all eligible skills under management" })).toBeVisible();
});

test("restores managed list scroll after switching tabs", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByLabel("Managed skills list")).toBeVisible();

await page.evaluate(() => {
const style = document.createElement("style");
style.textContent = ".skills-pane__content { min-height: 1200px; }";
document.head.appendChild(style);
const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
if (!scroller) {
throw new Error("Managed skills scroller was not rendered.");
}
scroller.scrollTop = 280;
});

const skillsTabs = page.getByRole("navigation", { name: "Skills views" });
await skillsTabs.getByRole("link", { name: /^Unmanaged/i }).click();
await expect(page.getByRole("heading", { name: "Unmanaged skills" })).toBeVisible();
await skillsTabs.getByRole("link", { name: /^Managed/i }).click();
await expect(page.getByRole("heading", { name: "Managed skills" })).toBeVisible();

await expect
.poll(async () => {
return page.evaluate(() => {
const scroller = document.querySelector(".skills-pane__scroll") as HTMLDivElement | null;
return scroller?.scrollTop ?? 0;
});
})
.toBe(280);
});

test("opens the Settings drawer", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Open settings" }).click();
await page.getByRole("link", { name: "Open settings" }).click();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Tools" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Harnesses" })).toBeVisible();
});

test("navigates to Marketplace", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: "Marketplace" }).click();
await expect(page.getByRole("heading", { name: "Marketplace" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Popular skills" })).toBeVisible();
await expect(page.getByAltText("Avatar for openclaw")).toBeVisible();
await expect(page.getByRole("link", { name: "mode-io/skills" })).toBeVisible();
await expect(page.getByAltText("Avatar for mode-io")).toBeVisible();
await expect(page.getByRole("heading", { name: "All-time leaderboard" })).toBeVisible();
await expect(page.getByRole("link", { name: "mode-io/skills" }).first()).toBeVisible();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createRef } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { SkillsPaneScaffold } from "./SkillsPaneScaffold";

describe("SkillsPaneScaffold", () => {
it("renders fixed chrome and a dedicated list scroller when ready", () => {
const scrollRef = createRef<HTMLDivElement>();

const { container } = render(
<SkillsPaneScaffold
title="Managed skills"
searchValue=""
hasActiveFilters={false}
onSearchChange={() => {}}
onReset={() => {}}
searchLabel="Managed skills filters"
searchInputLabel="Search managed skills"
searchPlaceholder="Search managed skills by name, description, or state"
scrollRef={scrollRef}
isReady={true}
isInitialLoading={false}
hasError={false}
loadingLabel="Loading managed skills"
errorMessage="Unable to load managed skills."
>
<div aria-label="Managed skills list">List body</div>
</SkillsPaneScaffold>,
);

expect(screen.getByRole("heading", { name: "Managed skills" })).toBeInTheDocument();
expect(screen.getByRole("search", { name: "Managed skills filters" })).toBeInTheDocument();
expect(screen.getByLabelText("Managed skills list")).toBeInTheDocument();
expect(container.querySelector(".skills-pane__scroll")).toBe(scrollRef.current);
});

it("renders loading and error states outside the ready pane content", () => {
render(
<SkillsPaneScaffold
title="Unmanaged skills"
searchValue=""
hasActiveFilters={false}
onSearchChange={() => {}}
onReset={() => {}}
searchLabel="Unmanaged skills filters"
searchInputLabel="Search unmanaged skills"
searchPlaceholder="Search unmanaged skills by name, description, or tool"
scrollRef={createRef<HTMLDivElement>()}
isReady={false}
isInitialLoading={true}
hasError={true}
loadingLabel="Loading unmanaged skills"
errorMessage="Unable to load unmanaged skills."
>
<div>Unused</div>
</SkillsPaneScaffold>,
);

expect(screen.getByRole("status", { name: "Loading unmanaged skills" })).toBeInTheDocument();
expect(screen.getByText("Unable to load unmanaged skills.")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Unmanaged skills" })).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { ReactNode, RefObject } from "react";

import { LoadingSpinner } from "../../../../components/LoadingSpinner";
import { SkillsPaneChrome } from "./SkillsPaneChrome";

interface SkillsPaneScaffoldProps {
title: string;
actions?: ReactNode;
searchValue: string;
hasActiveFilters: boolean;
onSearchChange: (value: string) => void;
onReset: () => void;
searchLabel: string;
searchInputLabel: string;
searchPlaceholder: string;
scrollRef: RefObject<HTMLDivElement | null>;
isReady: boolean;
isInitialLoading: boolean;
hasError: boolean;
loadingLabel: string;
errorMessage: string;
children: ReactNode;
}

export function SkillsPaneScaffold({
title,
actions,
searchValue,
hasActiveFilters,
onSearchChange,
onReset,
searchLabel,
searchInputLabel,
searchPlaceholder,
scrollRef,
isReady,
isInitialLoading,
hasError,
loadingLabel,
errorMessage,
children,
}: SkillsPaneScaffoldProps) {
return (
<section className="skills-pane">
{isReady ? (
<>
<SkillsPaneChrome
title={title}
actions={actions}
searchValue={searchValue}
hasActiveFilters={hasActiveFilters}
onSearchChange={onSearchChange}
onReset={onReset}
searchLabel={searchLabel}
searchInputLabel={searchInputLabel}
searchPlaceholder={searchPlaceholder}
/>

<div className="skills-pane__scroll ui-scrollbar" ref={scrollRef}>
<div className="skills-pane__content">{children}</div>
</div>
</>
) : null}

{isInitialLoading ? (
<div className="panel-state skills-pane__state">
<LoadingSpinner label={loadingLabel} />
</div>
) : null}

{hasError ? (
<div className="panel-state skills-pane__state">
<p>{errorMessage}</p>
</div>
) : null}
</section>
);
}
62 changes: 62 additions & 0 deletions frontend/src/features/skills/model/session.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { render, screen } from "@testing-library/react";
import { useRef } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { SkillsWorkspaceSessionProvider, useSkillsTabScroll } from "./session";

function ScrollProbe({ tab }: { tab: "managed" | "unmanaged" }) {
const elementRef = useRef<HTMLDivElement>(null);
useSkillsTabScroll(tab, true, elementRef);

return (
<div
ref={elementRef}
data-testid={`${tab}-scroll`}
/>
);
}

function SessionHarness({ tab }: { tab: "managed" | "unmanaged" }) {
return (
<SkillsWorkspaceSessionProvider>
<ScrollProbe key={tab} tab={tab} />
</SkillsWorkspaceSessionProvider>
);
}

describe("useSkillsTabScroll", () => {
beforeEach(() => {
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);

Object.defineProperty(HTMLElement.prototype, "scrollTo", {
configurable: true,
value(this: HTMLElement, options: ScrollToOptions) {
if (typeof options.top === "number") {
this.scrollTop = options.top;
}
},
});
});

it("stores and restores per-tab pane scroll positions without using window scroll", () => {
const { rerender } = render(<SessionHarness tab="managed" />);

const managedScroll = screen.getByTestId("managed-scroll") as HTMLDivElement;
managedScroll.scrollTop = 180;

rerender(<SessionHarness tab="unmanaged" />);

const unmanagedScroll = screen.getByTestId("unmanaged-scroll") as HTMLDivElement;
unmanagedScroll.scrollTop = 48;

rerender(<SessionHarness tab="managed" />);

expect((screen.getByTestId("managed-scroll") as HTMLDivElement).scrollTop).toBe(180);
expect(window.scrollTo).not.toHaveBeenCalled();
});
});
20 changes: 3 additions & 17 deletions frontend/src/features/skills/model/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,15 @@ export function useSkillsTabScroll(
const targetScrollTop = tab === "managed" ? context.managedScrollTop : context.unmanagedScrollTop;

useLayoutEffect(() => {
const usePaneScroll = shouldUsePaneScroll();
if (!ready || restoredRef.current || targetScrollTop === null) {
return;
}
if (usePaneScroll && !scrollRef.current) {
if (!scrollRef.current) {
return;
}
restoredRef.current = true;
const frame = window.requestAnimationFrame(() => {
if (usePaneScroll) {
scrollRef.current?.scrollTo({ top: targetScrollTop, behavior: "auto" });
return;
}
window.scrollTo({ top: targetScrollTop, behavior: "auto" });
scrollRef.current?.scrollTo({ top: targetScrollTop, behavior: "auto" });
});
return () => window.cancelAnimationFrame(frame);
}, [ready, scrollRef, targetScrollTop]);
Expand All @@ -134,21 +129,12 @@ export function useSkillsTabScroll(

useLayoutEffect(() => {
return () => {
const nextScrollTop = shouldUsePaneScroll()
? (scrollRef.current?.scrollTop ?? 0)
: window.scrollY;
const nextScrollTop = scrollRef.current?.scrollTop ?? 0;
context.setScrollPosition(tab, nextScrollTop);
};
}, [context, scrollRef, tab]);
}

function shouldUsePaneScroll(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia("(min-width: 1181px)").matches;
}

function useSkillsWorkspaceSession(): SkillsWorkspaceSessionContextValue {
const context = useContext(SkillsWorkspaceSessionContext);
if (!context) {
Expand Down
Loading
Loading