fix: stabilize publishers directory and e2e flows

This commit is contained in:
Peter Steinberger
2026-05-10 02:01:21 +01:00
parent dccb030dd6
commit edba7e1b12
17 changed files with 237 additions and 225 deletions
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+48 -38
View File
@@ -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
View File
@@ -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 }) => {
+8
View File
@@ -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 },
);
}
+17 -13
View File
@@ -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);
+2 -1
View File
@@ -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);
+16 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+11 -2
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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>
+16 -2
View File
@@ -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";
+4
View File
@@ -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;