diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e5ed4c6..ef739915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/convex/publishers.test.ts b/convex/publishers.test.ts index 75ef3ef9..1bc6fb5e 100644 --- a/convex/publishers.test.ts +++ b/convex/publishers.test.ts @@ -109,6 +109,13 @@ const updateProfileHandler = ( }> )._handler; +function indexedRows(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}`); }), diff --git a/convex/publishers.ts b/convex/publishers.ts index ba4016a2..428a05ce 100644 --- a/convex/publishers.ts +++ b/convex/publishers.ts @@ -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, + publisherId: Id<"publishers">, +): Promise { + 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 diff --git a/e2e/catalog-workflows.pw.test.ts b/e2e/catalog-workflows.pw.test.ts index f8f11b36..292f8d45 100644 --- a/e2e/catalog-workflows.pw.test.ts +++ b/e2e/catalog-workflows.pw.test.ts @@ -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(); }); diff --git a/e2e/ci-smoke.pw.test.ts b/e2e/ci-smoke.pw.test.ts index 6bf20cae..328bb482 100644 --- a/e2e/ci-smoke.pw.test.ts +++ b/e2e/ci-smoke.pw.test.ts @@ -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 }) => { diff --git a/e2e/helpers/runtimeErrors.ts b/e2e/helpers/runtimeErrors.ts index 85eebcc5..be7fa5be 100644 --- a/e2e/helpers/runtimeErrors.ts +++ b/e2e/helpers/runtimeErrors.ts @@ -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 }, + ); +} diff --git a/e2e/home-workflows.pw.test.ts b/e2e/home-workflows.pw.test.ts index a3a977d2..63c11684 100644 --- a/e2e/home-workflows.pw.test.ts +++ b/e2e/home-workflows.pw.test.ts @@ -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); diff --git a/e2e/menu-smoke.pw.test.ts b/e2e/menu-smoke.pw.test.ts index 146b763a..9cd97e5a 100644 --- a/e2e/menu-smoke.pw.test.ts +++ b/e2e/menu-smoke.pw.test.ts @@ -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); diff --git a/e2e/mobile-skills.pw.test.ts b/e2e/mobile-skills.pw.test.ts index 40ddb286..3f36919f 100644 --- a/e2e/mobile-skills.pw.test.ts +++ b/e2e/mobile-skills.pw.test.ts @@ -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(); diff --git a/e2e/search-exact.pw.test.ts b/e2e/search-exact.pw.test.ts index 1c8540fd..75dbd8b4 100644 --- a/e2e/search-exact.pw.test.ts +++ b/e2e/search-exact.pw.test.ts @@ -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>; - }; - 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); }); diff --git a/playwright.config.ts b/playwright.config.ts index a7dc1f14..74b8e400 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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: { diff --git a/src/lib/packageApi.ts b/src/lib/packageApi.ts index a312e006..4a667621 100644 --- a/src/lib/packageApi.ts +++ b/src/lib/packageApi.ts @@ -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(url: URL): Promise { - const response = await packageFetch(url, "application/json"); +async function fetchJson(url: URL, signal?: AbortSignal): Promise { + 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 { 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(url); + const result = await fetchJson(url, params.signal); return { items: result?.items ?? [], nextCursor: result?.nextCursor ?? null, diff --git a/src/lib/useUnifiedSearch.ts b/src/lib/useUnifiedSearch.ts index c42e4e69..20df5890 100644 --- a/src/lib/useUnifiedSearch.ts +++ b/src/lib/useUnifiedSearch.ts @@ -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, diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index e0a51831..99017a35 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -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 ( @@ -148,9 +157,7 @@ function RootDocument({ children }: { children: React.ReactNode }) { }, }} /> - - - + {showAnalytics ? : null} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 53f52045..930753d1 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -492,7 +492,7 @@ function SkillsHome() { value={query} onChange={(e) => setQuery(e.target.value)} /> - diff --git a/src/routes/skills/-useSkillsBrowseModel.ts b/src/routes/skills/-useSkillsBrowseModel.ts index 26b85072..91b3f86f 100644 --- a/src/routes/skills/-useSkillsBrowseModel.ts +++ b/src/routes/skills/-useSkillsBrowseModel.ts @@ -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"; diff --git a/src/styles.css b/src/styles.css index b1781c33..7c387d8e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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;