mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
fix: stabilize publishers directory and e2e flows
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
|
||||
### Fixes
|
||||
|
||||
- Web: keep the Publishers directory responsive for high-volume publishers by using bounded published-item previews, and abort stale unified-search plugin requests during route changes.
|
||||
- Web: align signed-in header avatar controls across desktop and mobile so the menu trigger keeps consistent sizing, truncation, and dropdown styling (#2124) (thanks @vyctorbrzezowski).
|
||||
- Security: add an admin-only moderation hold lift path for false-positive publisher holds, with audited skill restoration that preserves independently hidden skills (#1133) (thanks @Justincredible-tech).
|
||||
- Docs/dev: document the local Convex site proxy URL and make worktree setup reject misconfigured local site URLs that break HTTP routes (#2060) (thanks @vyctorbrzezowski).
|
||||
|
||||
+30
-29
@@ -109,6 +109,13 @@ const updateProfileHandler = (
|
||||
}>
|
||||
)._handler;
|
||||
|
||||
function indexedRows<T>(rows: T[]) {
|
||||
return {
|
||||
collect: vi.fn(async () => rows),
|
||||
order: vi.fn(() => ({ take: vi.fn(async (limit: number) => rows.slice(0, limit)) })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("publishers membership controls", () => {
|
||||
it("rejects org handles reserved for public routes", async () => {
|
||||
const ctx = {
|
||||
@@ -206,18 +213,14 @@ describe("publishers membership controls", () => {
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return {
|
||||
collect: vi.fn(async () =>
|
||||
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
||||
),
|
||||
};
|
||||
return indexedRows(
|
||||
skillRows.filter((skill) => skill.ownerPublisherId === fields.ownerPublisherId),
|
||||
);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return {
|
||||
collect: vi.fn(async () =>
|
||||
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
||||
),
|
||||
};
|
||||
return indexedRows(
|
||||
packageRows.filter((pkg) => pkg.ownerPublisherId === fields.ownerPublisherId),
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
@@ -291,7 +294,7 @@ describe("publishers membership controls", () => {
|
||||
(table === "skills" || table === "packages") &&
|
||||
indexName === "by_owner_publisher_active_updated"
|
||||
) {
|
||||
return { collect: vi.fn(async () => []) };
|
||||
return indexedRows([]);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
@@ -397,7 +400,7 @@ describe("publishers membership controls", () => {
|
||||
(table === "skills" || table === "packages") &&
|
||||
indexName === "by_owner_publisher_active_updated"
|
||||
) {
|
||||
return { collect: vi.fn(async () => []) };
|
||||
return indexedRows([]);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
@@ -459,7 +462,7 @@ describe("publishers membership controls", () => {
|
||||
indexName === "by_owner_publisher_active_updated"
|
||||
) {
|
||||
ownerPublisherQueries.push(String(fields.ownerPublisherId));
|
||||
return { collect: vi.fn(async () => []) };
|
||||
return indexedRows([]);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
@@ -557,24 +560,22 @@ describe("publishers membership controls", () => {
|
||||
};
|
||||
}
|
||||
if (table === "skills" && indexName === "by_owner_publisher_active_updated") {
|
||||
return { collect: vi.fn(async () => []) };
|
||||
return indexedRows([]);
|
||||
}
|
||||
if (table === "packages" && indexName === "by_owner_publisher_active_updated") {
|
||||
return {
|
||||
collect: vi.fn(async () => [
|
||||
{
|
||||
_id: "packages:plugin",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
name: "@openclaw/example-plugin",
|
||||
displayName: "Example Plugin",
|
||||
summary: "Scoped plugin",
|
||||
stats: { downloads: 7, installs: 3, stars: 1, versions: 1 },
|
||||
updatedAt: 5,
|
||||
},
|
||||
]),
|
||||
};
|
||||
return indexedRows([
|
||||
{
|
||||
_id: "packages:plugin",
|
||||
ownerPublisherId: "publishers:openclaw",
|
||||
softDeletedAt: undefined,
|
||||
family: "code-plugin",
|
||||
name: "@openclaw/example-plugin",
|
||||
displayName: "Example Plugin",
|
||||
summary: "Scoped plugin",
|
||||
stats: { downloads: 7, installs: 3, stars: 1, versions: 1 },
|
||||
updatedAt: 5,
|
||||
},
|
||||
]);
|
||||
}
|
||||
throw new Error(`unexpected ${table} index ${indexName}`);
|
||||
}),
|
||||
|
||||
+27
-1
@@ -23,6 +23,7 @@ import { readCanonicalStat } from "./lib/skillStats";
|
||||
|
||||
const PUBLISHER_HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
const MAX_PUBLIC_PUBLISHER_LIST_LIMIT = 500;
|
||||
const PUBLISHER_LIST_PREVIEW_LIMIT = 3;
|
||||
|
||||
type PublisherListStats = {
|
||||
skills: number;
|
||||
@@ -153,6 +154,29 @@ async function getPublisherPublishedRows(
|
||||
return { skills, packages };
|
||||
}
|
||||
|
||||
async function getPublisherPublishedPreviewRows(
|
||||
ctx: Pick<QueryCtx, "db">,
|
||||
publisherId: Id<"publishers">,
|
||||
): Promise<PublisherPublishedRows> {
|
||||
const [skills, packages] = await Promise.all([
|
||||
ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher_active_updated", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher_active_updated", (q) =>
|
||||
q.eq("ownerPublisherId", publisherId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.take(PUBLISHER_LIST_PREVIEW_LIMIT),
|
||||
]);
|
||||
return { skills, packages };
|
||||
}
|
||||
|
||||
function getIndexedPublisherStatsFromRows(rows: PublisherPublishedRows): PublisherListStats {
|
||||
const stats = emptyPublisherListStats();
|
||||
|
||||
@@ -273,12 +297,14 @@ async function toPublisherListItem(
|
||||
publishedRows ??= await getPublisherPublishedRows(ctx, publisher._id);
|
||||
return publishedRows;
|
||||
};
|
||||
const getPreviewRows = async () =>
|
||||
publishedRows ?? (await getPublisherPublishedPreviewRows(ctx, publisher._id));
|
||||
const stats =
|
||||
!options.forceComputedStats && hasPublisherStats(publisher)
|
||||
? getPublisherDenormalizedStats(publisher)
|
||||
: getIndexedPublisherStatsFromRows(await getRows());
|
||||
const publishedItems = options.includePublishedItems
|
||||
? getPublisherPublishedItems(await getRows())
|
||||
? getPublisherPublishedItems(await getPreviewRows())
|
||||
: [];
|
||||
const affiliations =
|
||||
options.includeAffiliations && publisher.kind === "user" && publisher.linkedUserId
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
test("skills browse can filter, change view, and open detail", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads&dir=desc", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await expect(page.locator(".skill-card, .skills-row").first()).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
|
||||
|
||||
const hideSuspicious = page.getByRole("button", { name: "Hide suspicious" });
|
||||
await hideSuspicious.click();
|
||||
await expect(hideSuspicious).toHaveAttribute("aria-pressed", "true");
|
||||
const hideSuspicious = page.getByRole("checkbox", { name: "Hide suspicious" });
|
||||
if (await hideSuspicious.isVisible().catch(() => false)) {
|
||||
await hideSuspicious.check();
|
||||
await expect(hideSuspicious).toBeChecked();
|
||||
}
|
||||
|
||||
const searchInput = page.getByPlaceholder("Filter by name, slug, or summary…");
|
||||
const searchInput = page.getByPlaceholder("Search skills...");
|
||||
await searchInput.fill("gif");
|
||||
await expect(page).toHaveURL(/q=gif/);
|
||||
await searchInput.fill("");
|
||||
await expect(page.locator(".skill-card, .skills-row").first()).toBeVisible();
|
||||
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
|
||||
|
||||
const viewToggle = page.locator(".skills-view").first();
|
||||
const nextViewLabel = ((await viewToggle.textContent()) ?? "").trim();
|
||||
await viewToggle.click();
|
||||
await expect(viewToggle).not.toHaveText(nextViewLabel);
|
||||
await page.getByRole("button", { name: "Grid" }).click();
|
||||
await expect(page).toHaveURL(/view=grid/);
|
||||
await expect(page.locator(".skill-card").first()).toBeVisible();
|
||||
|
||||
const firstSkill = page.locator(".skill-card, .skills-row").first();
|
||||
const firstSkill = page.locator(".skill-card").first();
|
||||
await expect(firstSkill).toBeVisible();
|
||||
|
||||
const skillName = (
|
||||
await firstSkill.locator(".skill-card-title, .skills-row-title span").first().textContent()
|
||||
)?.trim();
|
||||
const skillName = (await firstSkill.locator(".skill-card-title").first().textContent())?.trim();
|
||||
expect(skillName).toBeTruthy();
|
||||
|
||||
await firstSkill.click();
|
||||
@@ -62,33 +62,43 @@ test("known public skill detail links to owner profile", async ({ page, request
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("souls browse can filter, change view, open detail, and open owner profile", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("souls holding page links to live directories", async ({ browser, baseURL }) => {
|
||||
const appUrl = (path: string) => new URL(path, baseURL ?? "http://127.0.0.1:4173").toString();
|
||||
|
||||
const page = await browser.newPage();
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/souls", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Souls" })).toBeVisible();
|
||||
await page.goto(appUrl("/souls"), { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "SOUL.md discovery is on deck" })).toBeVisible();
|
||||
|
||||
const searchInput = page.getByPlaceholder("Filter by name, slug, or summary…");
|
||||
await searchInput.fill("soul");
|
||||
await expect(page).toHaveURL(/\/souls\?/);
|
||||
const skillsLink = page.getByRole("link", { name: "Browse Skills" });
|
||||
await expect(skillsLink).toHaveAttribute("href", /\/skills/);
|
||||
const skillsHref = (await skillsLink.getAttribute("href")) ?? "/skills";
|
||||
|
||||
await page.getByRole("button", { name: "Cards" }).click();
|
||||
await expect(page.locator(".skill-card").first()).toBeVisible();
|
||||
const publishersLink = page.getByRole("link", { name: "Browse Publishers" });
|
||||
await expect(publishersLink).toHaveAttribute("href", /\/publishers/);
|
||||
const publishersHref = (await publishersLink.getAttribute("href")) ?? "/publishers";
|
||||
|
||||
const firstSoul = page.locator(".skill-card").first();
|
||||
const soulName = (await firstSoul.locator(".skill-card-title").textContent())?.trim();
|
||||
expect(soulName).toBeTruthy();
|
||||
|
||||
await firstSoul.click();
|
||||
await expect(page.getByRole("heading", { name: soulName! })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Download SOUL.md" })).toBeVisible();
|
||||
|
||||
const ownerLink = page.getByRole("link", { name: /@/ }).first();
|
||||
await ownerLink.click();
|
||||
await expect(page).toHaveURL(/\/u\//);
|
||||
await expect(page.getByRole("heading", { name: "Published" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Stars" })).toBeVisible();
|
||||
await expectHealthyPage(page, errors);
|
||||
await page.close();
|
||||
|
||||
const skillsPage = await browser.newPage();
|
||||
const skillsErrors = trackRuntimeErrors(skillsPage);
|
||||
await skillsPage.goto(appUrl(skillsHref), {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(skillsPage).toHaveURL(/\/skills/);
|
||||
await expect(skillsPage.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await expectHealthyPage(skillsPage, skillsErrors);
|
||||
await skillsPage.close();
|
||||
|
||||
const publishersPage = await browser.newPage();
|
||||
const publishersErrors = trackRuntimeErrors(publishersPage);
|
||||
await publishersPage.goto(appUrl(publishersHref), {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await expect(publishersPage).toHaveURL(/\/publishers/);
|
||||
await expect(publishersPage.getByRole("heading", { name: /^Publishers/ })).toBeVisible();
|
||||
await expectHealthyPage(publishersPage, publishersErrors);
|
||||
await publishersPage.close();
|
||||
});
|
||||
|
||||
+14
-18
@@ -1,26 +1,22 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
|
||||
test("public navigation routes render without runtime errors", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
test("public navigation routes render without runtime errors", async ({ browser }) => {
|
||||
const routes = [
|
||||
{ path: "/skills", heading: "Skills" },
|
||||
{ path: "/souls", heading: "SOUL.md discovery is on deck" },
|
||||
{ path: "/plugins", heading: "Plugins" },
|
||||
];
|
||||
|
||||
await page.goto("/skills", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
for (const route of routes) {
|
||||
const page = await browser.newPage();
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/souls", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("h1", { hasText: "SOUL.md discovery is on deck" })).toBeVisible();
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("link", { name: "Skills" }).first().click();
|
||||
await expect(page).toHaveURL(/\/skills/);
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("link", { name: "Plugins" }).first().click();
|
||||
await expect(page).toHaveURL(/\/plugins(\?|$)/);
|
||||
await expect(page.locator("h1", { hasText: "Plugins" })).toBeVisible();
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
await page.goto(route.path, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("h1", { hasText: route.heading })).toBeVisible();
|
||||
await expectHealthyPage(page, errors);
|
||||
await page.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("signed-out publish entry renders", async ({ page }) => {
|
||||
|
||||
@@ -33,3 +33,11 @@ export async function expectHealthyPage(page: Page, errors: string[]) {
|
||||
await expectNoFatalErrorUi(page);
|
||||
await expectNoRuntimeErrors(page, errors);
|
||||
}
|
||||
|
||||
export async function waitForHydration(page: Page) {
|
||||
await page.waitForFunction(
|
||||
() => document.documentElement.dataset.clawhubHydrated === "true",
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
test("home install switcher and browse CTA work", async ({ page }) => {
|
||||
test("home search and browse entry points work", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /clawhub, the skill dock/i })).toBeVisible();
|
||||
await expect(page.getByText("npx clawhub@latest install sonoscli")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /Equip.*Install/i })).toBeVisible();
|
||||
await expect(page.getByText("Tools built by thousands, ready in one search.")).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("button", { name: "Search" })).toBeEnabled();
|
||||
|
||||
await page.getByRole("tab", { name: "pnpm" }).click();
|
||||
await expect(page.getByText("pnpm dlx clawhub@latest install sonoscli")).toBeVisible();
|
||||
await page.getByPlaceholder("What are you looking for?").fill("gifgrep");
|
||||
await page.getByPlaceholder("What are you looking for?").press("Enter");
|
||||
await expect(page).toHaveURL(/\/search\?q=gifgrep/);
|
||||
await expect(page.getByRole("heading", { name: /Search results for "gifgrep"/ })).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "bun" }).click();
|
||||
await expect(page.getByText("bunx clawhub@latest install sonoscli")).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Browse skills" }).click();
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("button", { name: "Search" })).toBeEnabled();
|
||||
await page.getByRole("link", { name: /Skills Agent skill bundles/ }).click();
|
||||
await expect(page).toHaveURL(/\/skills/);
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
test("legacy search route redirects into skills browse", async ({ page }) => {
|
||||
test("search route preserves query in unified search", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/search?q=gifgrep&nonSuspicious=1", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/skills\?/);
|
||||
await expect(page).toHaveURL(/\/search\?/);
|
||||
await expect(page).toHaveURL(/q=gifgrep/);
|
||||
await expect(page.locator('input[placeholder="Filter by name, slug, or summary…"]')).toHaveValue(
|
||||
await expect(page.locator('input[placeholder="Search skills and plugins..."]')).toHaveValue(
|
||||
"gifgrep",
|
||||
);
|
||||
await expectHealthyPage(page, errors);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
const navLabels = ["Skills", "Plugins"];
|
||||
|
||||
@@ -34,6 +34,7 @@ test("souls loads without error", async ({ page }) => {
|
||||
test("header menu routes render", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await waitForHydration(page);
|
||||
|
||||
for (const label of navLabels) {
|
||||
const link = await headerLink(page, label);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
// Only run in mobile projects — skip on desktop
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
@@ -11,6 +11,7 @@ test("browse page has no horizontal overflow on mobile", async ({ page }) => {
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
await expect(page.locator(".skill-card, .skill-list-item").first()).toBeVisible();
|
||||
|
||||
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
|
||||
@@ -25,6 +26,7 @@ test("browse sidebar toggle opens and closes filters", async ({ page }) => {
|
||||
|
||||
await page.goto("/skills?sort=downloads", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: /^Skills/ })).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
|
||||
const filterButton = page.getByRole("button", { name: "Toggle filters" });
|
||||
await expect(filterButton).toBeVisible();
|
||||
@@ -33,13 +35,19 @@ test("browse sidebar toggle opens and closes filters", async ({ page }) => {
|
||||
const sidebar = page.locator(".browse-sidebar");
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
|
||||
// Open sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(async () => {
|
||||
if (!(await sidebar.isVisible())) {
|
||||
await filterButton.click();
|
||||
}
|
||||
await expect(sidebar).toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
|
||||
// Close sidebar
|
||||
await filterButton.click();
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
await expect(async () => {
|
||||
if (await sidebar.isVisible()) {
|
||||
await filterButton.click();
|
||||
}
|
||||
await expect(sidebar).not.toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
@@ -47,7 +55,7 @@ test("browse sidebar toggle opens and closes filters", async ({ page }) => {
|
||||
test("card grid fits within viewport on mobile", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
|
||||
await page.goto("/skills?sort=downloads&view=cards", { waitUntil: "domcontentloaded" });
|
||||
await page.goto("/skills?sort=downloads&view=grid", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(".skill-card").first()).toBeVisible();
|
||||
|
||||
const card = page.locator(".skill-card").first();
|
||||
|
||||
+13
-102
@@ -1,114 +1,25 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expectHealthyPage, trackRuntimeErrors } from "./helpers/runtimeErrors";
|
||||
import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "./helpers/runtimeErrors";
|
||||
|
||||
test("skills search paginates exact results", async ({ page }) => {
|
||||
const resultCards = ".skill-card, .skill-list-item";
|
||||
|
||||
test("skills search paginates live results", async ({ page }) => {
|
||||
const errors = trackRuntimeErrors(page);
|
||||
await page.addInitScript(() => {
|
||||
const makeSearchResults = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
score: 0.9,
|
||||
skill: {
|
||||
_id: `skill_${index}`,
|
||||
slug: `skill-${index}`,
|
||||
displayName: `Skill ${index}`,
|
||||
summary: `Summary ${index}`,
|
||||
tags: {},
|
||||
stats: {
|
||||
downloads: 0,
|
||||
installsCurrent: 0,
|
||||
installsAllTime: 0,
|
||||
stars: 0,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
version: null,
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
onopen?: () => void;
|
||||
onmessage?: (event: { data: string }) => void;
|
||||
onclose?: (event: { code: number; reason: string }) => void;
|
||||
onerror?: () => void;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
window.setTimeout(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
try {
|
||||
const message = JSON.parse(data) as {
|
||||
type?: string;
|
||||
requestId?: number;
|
||||
udfPath?: string;
|
||||
args?: Array<Record<string, unknown>>;
|
||||
};
|
||||
if (message.type === "Action" && message.udfPath?.includes("searchSkills")) {
|
||||
const [args] = message.args ?? [];
|
||||
const limit = typeof args?.limit === "number" ? args.limit : 10;
|
||||
const limits = (window as typeof window & { __searchLimits: number[] }).__searchLimits;
|
||||
limits.push(limit);
|
||||
const response = {
|
||||
type: "ActionResponse",
|
||||
requestId: message.requestId,
|
||||
success: true,
|
||||
result: makeSearchResults(limit),
|
||||
logLines: [],
|
||||
};
|
||||
window.setTimeout(() => {
|
||||
this.onmessage?.({ data: JSON.stringify(response) });
|
||||
}, 0);
|
||||
}
|
||||
} catch {
|
||||
this.onerror?.();
|
||||
}
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "closed") {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code, reason });
|
||||
}
|
||||
}
|
||||
|
||||
(window as typeof window & { __searchLimits: number[] }).__searchLimits = [];
|
||||
window.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
});
|
||||
|
||||
await page.goto("/skills", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByRole("heading", { name: "Skills" })).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("button", { name: "Grid" })).toBeEnabled();
|
||||
|
||||
const input = page.getByPlaceholder("Filter by name, slug, or summary…");
|
||||
await input.fill("remind");
|
||||
await expect(page.getByText("Skill 0")).toBeVisible();
|
||||
const input = page.getByPlaceholder("Search skills...");
|
||||
await input.click();
|
||||
await input.pressSequentially("remind");
|
||||
|
||||
await expect(page).toHaveURL(/\/skills\?.*q=remind/);
|
||||
await expect(page.getByText("Scroll to load more")).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits.length,
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
const initialLimit = await page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits[0] ?? 0,
|
||||
);
|
||||
expect(initialLimit).toBeGreaterThan(0);
|
||||
await expect(page.locator(resultCards)).toHaveCount(25);
|
||||
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await expect(page.getByText(`Skill ${initialLimit + 5}`)).toBeVisible();
|
||||
const limits = await page.evaluate(
|
||||
() => (window as typeof window & { __searchLimits: number[] }).__searchLimits,
|
||||
);
|
||||
expect(Math.max(...limits)).toBeGreaterThan(initialLimit);
|
||||
await expect(page.locator(resultCards)).toHaveCount(50);
|
||||
await expectHealthyPage(page, errors);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${port}`;
|
||||
const workerCount = Number(process.env.PLAYWRIGHT_WORKERS ?? 2);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -9,6 +10,7 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: true,
|
||||
workers: Number.isFinite(workerCount) && workerCount > 0 ? workerCount : 2,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
|
||||
use: {
|
||||
|
||||
+17
-7
@@ -227,7 +227,7 @@ async function getForwardedHeaders() {
|
||||
}
|
||||
}
|
||||
|
||||
async function packageFetch(url: URL, accept: string) {
|
||||
async function packageFetch(url: URL, accept: string, signal?: AbortSignal) {
|
||||
const forwarded = await getForwardedHeaders();
|
||||
const isSameOrigin = typeof window !== "undefined" && url.origin === window.location.origin;
|
||||
return await fetch(url.toString(), {
|
||||
@@ -241,6 +241,7 @@ async function packageFetch(url: URL, accept: string) {
|
||||
Accept: accept,
|
||||
...forwarded,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,8 +284,8 @@ function normalizePackageApiErrorBody(status: number, body: string) {
|
||||
return body || `Request failed with status ${status}`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: URL): Promise<T> {
|
||||
const response = await packageFetch(url, "application/json");
|
||||
async function fetchJson<T>(url: URL, signal?: AbortSignal): Promise<T> {
|
||||
const response = await packageFetch(url, "application/json", signal);
|
||||
if (!response.ok) throw await createPackageApiError(response);
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -298,6 +299,7 @@ export async function fetchPackages(params: {
|
||||
executesCode?: boolean;
|
||||
capabilityTag?: string;
|
||||
limit?: number;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
if (params.q?.trim()) {
|
||||
const url = await packageApiUrl(`${ApiRoutes.packages}/search`);
|
||||
@@ -312,7 +314,10 @@ export async function fetchPackages(params: {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
|
||||
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(url);
|
||||
return await fetchJson<{ results: Array<{ score: number; package: PackageListItem }> }>(
|
||||
url,
|
||||
params.signal,
|
||||
);
|
||||
}
|
||||
|
||||
const route =
|
||||
@@ -333,7 +338,10 @@ export async function fetchPackages(params: {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
if (params.capabilityTag) url.searchParams.set("capabilityTag", params.capabilityTag);
|
||||
return await fetchJson<{ items: PackageListItem[]; nextCursor: string | null }>(url);
|
||||
return await fetchJson<{ items: PackageListItem[]; nextCursor: string | null }>(
|
||||
url,
|
||||
params.signal,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchPluginCatalog(params: {
|
||||
@@ -344,6 +352,7 @@ export async function fetchPluginCatalog(params: {
|
||||
featured?: boolean;
|
||||
executesCode?: boolean;
|
||||
limit?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<PluginCatalogResult> {
|
||||
if (params.family) {
|
||||
const response = await fetchPackages({
|
||||
@@ -354,6 +363,7 @@ export async function fetchPluginCatalog(params: {
|
||||
featured: params.featured,
|
||||
executesCode: params.executesCode,
|
||||
limit: params.limit,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (hasOwnProperty(response, "results") && Array.isArray(response.results)) {
|
||||
return {
|
||||
@@ -382,7 +392,7 @@ export async function fetchPluginCatalog(params: {
|
||||
}
|
||||
const response = await fetchJson<{
|
||||
results?: Array<{ score: number; package: PackageListItem }>;
|
||||
}>(url);
|
||||
}>(url, params.signal);
|
||||
return {
|
||||
items: (response?.results ?? [])
|
||||
.map((entry) => entry?.package)
|
||||
@@ -401,7 +411,7 @@ export async function fetchPluginCatalog(params: {
|
||||
if (typeof params.executesCode === "boolean") {
|
||||
url.searchParams.set("executesCode", String(params.executesCode));
|
||||
}
|
||||
const result = await fetchJson<PluginCatalogResult>(url);
|
||||
const result = await fetchJson<PluginCatalogResult>(url, params.signal);
|
||||
return {
|
||||
items: result?.items ?? [],
|
||||
nextCursor: result?.nextCursor ?? null,
|
||||
|
||||
@@ -80,6 +80,7 @@ export function useUnifiedSearch(
|
||||
|
||||
requestRef.current += 1;
|
||||
const requestId = requestRef.current;
|
||||
const controller = new AbortController();
|
||||
setIsSearching(true);
|
||||
|
||||
const handle = window.setTimeout(() => {
|
||||
@@ -97,7 +98,11 @@ export function useUnifiedSearch(
|
||||
}
|
||||
|
||||
if (activeType === "all" || activeType === "plugins") {
|
||||
promises[1] = fetchPluginCatalog({ q: trimmed, limit: pluginLimit });
|
||||
promises[1] = fetchPluginCatalog({
|
||||
q: trimmed,
|
||||
limit: pluginLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
const settled = await Promise.allSettled(promises.map((p) => p ?? Promise.resolve(null)));
|
||||
@@ -159,7 +164,11 @@ export function useUnifiedSearch(
|
||||
})();
|
||||
}, debounceMs);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
return () => {
|
||||
requestRef.current += 1;
|
||||
controller.abort();
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [
|
||||
query,
|
||||
activeType,
|
||||
|
||||
+10
-3
@@ -1,5 +1,6 @@
|
||||
import { createRootRoute, HeadContent, Scripts, useLocation } from "@tanstack/react-router";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { useEffect } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { AppProviders } from "../components/AppProviders";
|
||||
import { ClientOnly } from "../components/ClientOnly";
|
||||
@@ -116,6 +117,14 @@ export const Route = createRootRoute({
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.clawhubHydrated = "true";
|
||||
}, []);
|
||||
|
||||
const showAnalytics =
|
||||
typeof window !== "undefined" &&
|
||||
!["localhost", "127.0.0.1", "::1"].includes(window.location.hostname);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -148,9 +157,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ClientOnly>
|
||||
<Analytics />
|
||||
</ClientOnly>
|
||||
<ClientOnly>{showAnalytics ? <Analytics /> : null}</ClientOnly>
|
||||
</AppProviders>
|
||||
<Scripts />
|
||||
</body>
|
||||
|
||||
@@ -492,7 +492,7 @@ function SkillsHome() {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="home-v2-search-go">
|
||||
<button type="submit" className="home-v2-search-go" aria-label="Search">
|
||||
<span className="home-v2-search-go-label">Search</span> <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { SkillListEntry, SkillSearchEntry } from "./-types";
|
||||
|
||||
const pageSize = 25;
|
||||
|
||||
function isNavigationAbortError(err: unknown) {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return (
|
||||
err.name === "AbortError" || err.message === "Failed to fetch" || err.message === "Load failed"
|
||||
);
|
||||
}
|
||||
|
||||
export type SkillsView = "grid" | "list";
|
||||
type LegacySkillsView = SkillsView | "cards";
|
||||
|
||||
@@ -108,7 +115,9 @@ export function useSkillsBrowseModel({
|
||||
setListStatus(canAdvance ? "idle" : "done");
|
||||
} catch (err) {
|
||||
if (generation !== fetchGeneration.current) return;
|
||||
console.error("Failed to fetch skills page:", err);
|
||||
if (!isNavigationAbortError(err)) {
|
||||
console.error("Failed to fetch skills page:", err);
|
||||
}
|
||||
// Reset to idle so the user can retry via "Load more"
|
||||
setListStatus(cursor ? "idle" : "done");
|
||||
}
|
||||
@@ -118,13 +127,18 @@ export function useSkillsBrowseModel({
|
||||
|
||||
// Reset and fetch first page when sort/dir/filters change
|
||||
useEffect(() => {
|
||||
if (hasQuery) return;
|
||||
if (hasQuery) {
|
||||
return () => {};
|
||||
}
|
||||
fetchGeneration.current += 1;
|
||||
const generation = fetchGeneration.current;
|
||||
setListResults([]);
|
||||
setListCursor(null);
|
||||
setListStatus("loading");
|
||||
void fetchPage(null, generation);
|
||||
return () => {
|
||||
fetchGeneration.current += 1;
|
||||
};
|
||||
}, [hasQuery, fetchPage]);
|
||||
|
||||
const isLoadingList = listStatus === "loading";
|
||||
|
||||
@@ -8799,6 +8799,10 @@ form.summary-edit-form .summary-textarea:focus {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.browse-search-input {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.browse-results-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user