diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ce96ee946..cfde8dab9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,7 @@ jobs: e2e/local-auth/header-profile-link.pw.test.ts e2e/local-auth/manage-context-proof.pw.test.ts e2e/local-auth/publisher-github-profile.pw.test.ts + e2e/local-auth/skill-hero-layout.pw.test.ts - name: moderation-malicious specs: e2e/local-auth/malicious-skill-ban-flow.pw.test.ts - name: star-sync diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb8123490..0828b792ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixes +- CI: run skill hero layout checks against a seeded local catalog entry so third-party listing removal cannot break public smoke tests. + - Deploy: coalesce pending skills.sh syncs per ref before they enter the production deployment queue, while preserving active sync cleanup and queued manual deploys. - Workers: reserve Skill Card capacity by lease slot to avoid global queue contention, continue after partial batches, and release undelivered leases when input hydration fails. diff --git a/e2e/local-auth/skill-hero-layout.pw.test.ts b/e2e/local-auth/skill-hero-layout.pw.test.ts new file mode 100644 index 0000000000..0f509049f4 --- /dev/null +++ b/e2e/local-auth/skill-hero-layout.pw.test.ts @@ -0,0 +1,181 @@ +import { spawnSync } from "node:child_process"; +import { expect, test } from "@playwright/test"; +import { expectHealthyPage, trackRuntimeErrors, waitForHydration } from "../helpers/runtimeErrors"; + +test.skip( + process.env.VITE_ENABLE_DEV_AUTH !== "1", + "skill hero layout requires the seeded local Convex fixture", +); + +test.beforeAll(() => { + const result = spawnSync( + "bunx", + ["convex", "run", "--no-push", "devSeed:seedCanonicalSearchFixture"], + { encoding: "utf8", timeout: 60_000 }, + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); +}); + +test("skill hero metadata keeps semantic wrap groups on mobile", async ({ page }) => { + const errors = trackRuntimeErrors(page); + await page.route("**/_vercel/image?**", (route) => route.fulfill({ status: 204 })); + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto("/skills-sh/doany-skills/skills/reddit-automation", { + waitUntil: "domcontentloaded", + }); + await waitForHydration(page); + + const taxonomy = page.getByLabel("Skill metadata"); + const source = taxonomy.locator(".skill-hero-taxonomy-prefix"); + const categories = taxonomy.locator(".skill-category-meta-link"); + const category = categories.first(); + const topics = taxonomy.locator(".skill-hero-topic"); + const creatorName = page.locator(".skill-hero-creator .user-name"); + const creatorHandle = page.locator(".skill-hero-creator .user-handle"); + + await expect(taxonomy).toBeVisible(); + await expect(source).toContainText("Synced from skills.sh"); + await expect(category).toBeVisible(); + await expect(topics.first()).toBeVisible(); + await expect(creatorName).toBeVisible(); + await expect(creatorHandle).toBeVisible(); + + const taxonomyWeights = await taxonomy + .locator(".skills-sh-sync-source-label, .skill-category-meta-link, .skill-hero-topic") + .evaluateAll((elements) => elements.map((element) => getComputedStyle(element).fontWeight)); + expect(new Set(taxonomyWeights)).toEqual(new Set(["400"])); + await expect(creatorHandle).toHaveCSS("font-weight", "400"); + await expect(creatorName).not.toHaveCSS("font-weight", "400"); + const creatorTypeSizes = await Promise.all( + [creatorName, creatorHandle].map(async (locator) => + Number.parseFloat(await locator.evaluate((element) => getComputedStyle(element).fontSize)), + ), + ); + expect(creatorTypeSizes[1]).toBeLessThan(creatorTypeSizes[0]!); + await expect(creatorHandle).toHaveCSS("font-size", "12px"); + const secondaryColor = await creatorHandle.evaluate((handleElement) => { + const secondaryProbe = document.createElement("span"); + secondaryProbe.style.color = "var(--ink-soft)"; + handleElement.parentElement!.append(secondaryProbe); + const color = getComputedStyle(secondaryProbe).color; + secondaryProbe.remove(); + return color; + }); + // Hydration can start a theme color transition; wait for its observable result. + await expect(creatorHandle).toHaveCSS("color", secondaryColor); + await expect(creatorName).not.toHaveCSS("color", secondaryColor); + await expect(taxonomy.locator(".skill-hero-taxonomy-separator").first()).not.toHaveCSS( + "display", + "none", + ); + + await page.setViewportSize({ width: 600, height: 900 }); + // Flex items compute inline-flex as flex; prove their layout instead of the authored keyword. + const fittingGroupTops = await Promise.all( + [source, category, topics.first()].map(async (locator) => (await locator.boundingBox())?.y), + ); + expect(fittingGroupTops.every((top) => top !== undefined)).toBe(true); + expect( + Math.max(...(fittingGroupTops as number[])) - Math.min(...(fittingGroupTops as number[])), + ).toBeLessThanOrEqual(1); + + const mobileSeparatorStyles = await taxonomy + .locator(".skill-category-meta-link, .skill-hero-topic") + .evaluateAll((elements) => + elements.map((element) => { + const style = getComputedStyle(element, "::before"); + return { + backgroundColor: style.backgroundColor, + content: style.content, + height: style.height, + width: style.width, + }; + }), + ); + expect(mobileSeparatorStyles.length).toBeGreaterThanOrEqual(2); + expect( + mobileSeparatorStyles.every( + (style) => + style.content !== "none" && + style.content !== "normal" && + style.width === "1px" && + style.height === "14px" && + style.backgroundColor !== "rgba(0, 0, 0, 0)", + ), + ).toBe(true); + + await category.evaluate((categoryTemplate) => { + const list = categoryTemplate.parentElement; + if (!list) throw new Error("Category list is missing"); + const labels = ["Communication", "Productivity", "Development"]; + labels.forEach((label, index) => { + const categoryItem = index === 0 ? categoryTemplate : categoryTemplate.cloneNode(true); + if (!(categoryItem instanceof HTMLAnchorElement)) { + throw new Error("Category template is incomplete"); + } + const labelElement = categoryItem.querySelector("span:last-child"); + if (!labelElement) throw new Error("Category label is missing"); + categoryItem.href = `/skills?category=${label.toLowerCase()}`; + categoryItem.setAttribute("aria-label", `View ${label} skills`); + labelElement.textContent = label; + if (index > 0) list.append(categoryItem); + }); + }); + await expect(categories).toHaveCount(3); + + const initialTopicCount = await topics.count(); + await topics.first().evaluate((topicTemplate) => { + const list = topicTemplate.parentElement; + if (!list) throw new Error("Topic list is missing"); + const labels = ["#video-generation", "#creative-production", "#automation-workflows"]; + labels.forEach((label, index) => { + const topicItem = index === 0 ? topicTemplate : topicTemplate.cloneNode(true); + if (!(topicItem instanceof HTMLAnchorElement)) { + throw new Error("Topic template is incomplete"); + } + topicItem.href = `/topics/${label.slice(1)}`; + topicItem.setAttribute("aria-label", `View skills tagged ${label}`); + topicItem.textContent = label; + if (index > 0) list.append(topicItem); + }); + }); + await expect(topics).toHaveCount(initialTopicCount + 2); + + await page.setViewportSize({ width: 320, height: 900 }); + const wrappedGroupTops = await Promise.all( + [source, category, topics.first()].map(async (locator) => (await locator.boundingBox())?.y), + ); + expect(wrappedGroupTops.every((top) => top !== undefined)).toBe(true); + expect( + Math.max(...(wrappedGroupTops as number[])) - Math.min(...(wrappedGroupTops as number[])), + ).toBeGreaterThan(1); + + const categoryTops = await categories.evaluateAll((elements) => + elements.map((element) => Math.round(element.getBoundingClientRect().top)), + ); + expect(new Set(categoryTops).size).toBeGreaterThan(1); + + const topicTops = await topics.evaluateAll((elements) => + elements.map((element) => Math.round(element.getBoundingClientRect().top)), + ); + expect(new Set(topicTops).size).toBeGreaterThan(1); + + const semanticUnitStyles = await taxonomy + .locator(".skills-sh-sync-source-label, .skill-category-meta-link, .skill-hero-topic") + .evaluateAll((elements) => + elements.map((element) => ({ + rects: element.getClientRects().length, + whiteSpace: getComputedStyle(element).whiteSpace, + })), + ); + expect( + semanticUnitStyles.every((style) => style.rects === 1 && style.whiteSpace === "nowrap"), + ).toBe(true); + + const overflow = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 1); + await expectHealthyPage(page, errors); +}); diff --git a/e2e/public-routes-smoke.pw.test.ts b/e2e/public-routes-smoke.pw.test.ts index 03f75e0a24..55b7d37c89 100644 --- a/e2e/public-routes-smoke.pw.test.ts +++ b/e2e/public-routes-smoke.pw.test.ts @@ -231,169 +231,6 @@ for (const route of publicRouteCases()) { }); } -test("skill hero metadata keeps semantic wrap groups on mobile", async ({ page }) => { - const errors = trackRuntimeErrors(page); - await page.route("**/_vercel/image?**", (route) => route.fulfill({ status: 204 })); - await page.setViewportSize({ width: 1280, height: 900 }); - await page.goto("/skills-sh/skills-101/superpowers/ai-video-generation", { - waitUntil: "domcontentloaded", - }); - await waitForHydration(page); - - const taxonomy = page.getByLabel("Skill metadata"); - const source = taxonomy.locator(".skill-hero-taxonomy-prefix"); - const categories = taxonomy.locator(".skill-category-meta-link"); - const category = categories.first(); - const topics = taxonomy.locator(".skill-hero-topic"); - const creatorName = page.locator(".skill-hero-creator .user-name"); - const creatorHandle = page.locator(".skill-hero-creator .user-handle"); - - await expect(taxonomy).toBeVisible(); - await expect(source).toContainText("Synced from skills.sh"); - await expect(category).toBeVisible(); - await expect(topics.first()).toBeVisible(); - await expect(creatorName).toBeVisible(); - await expect(creatorHandle).toBeVisible(); - - const taxonomyWeights = await taxonomy - .locator(".skills-sh-sync-source-label, .skill-category-meta-link, .skill-hero-topic") - .evaluateAll((elements) => elements.map((element) => getComputedStyle(element).fontWeight)); - expect(new Set(taxonomyWeights)).toEqual(new Set(["400"])); - await expect(creatorHandle).toHaveCSS("font-weight", "400"); - await expect(creatorName).not.toHaveCSS("font-weight", "400"); - const creatorTypeSizes = await Promise.all( - [creatorName, creatorHandle].map(async (locator) => - Number.parseFloat(await locator.evaluate((element) => getComputedStyle(element).fontSize)), - ), - ); - expect(creatorTypeSizes[1]).toBeLessThan(creatorTypeSizes[0]!); - await expect(creatorHandle).toHaveCSS("font-size", "12px"); - const secondaryColor = await creatorHandle.evaluate((handleElement) => { - const secondaryProbe = document.createElement("span"); - secondaryProbe.style.color = "var(--ink-soft)"; - handleElement.parentElement!.append(secondaryProbe); - const color = getComputedStyle(secondaryProbe).color; - secondaryProbe.remove(); - return color; - }); - // Hydration can start a theme color transition; wait for its observable result. - await expect(creatorHandle).toHaveCSS("color", secondaryColor); - await expect(creatorName).not.toHaveCSS("color", secondaryColor); - await expect(taxonomy.locator(".skill-hero-taxonomy-separator").first()).not.toHaveCSS( - "display", - "none", - ); - - await page.setViewportSize({ width: 600, height: 900 }); - // Flex items compute inline-flex as flex; prove their layout instead of the authored keyword. - const fittingGroupTops = await Promise.all( - [source, category, topics.first()].map(async (locator) => (await locator.boundingBox())?.y), - ); - expect(fittingGroupTops.every((top) => top !== undefined)).toBe(true); - expect( - Math.max(...(fittingGroupTops as number[])) - Math.min(...(fittingGroupTops as number[])), - ).toBeLessThanOrEqual(1); - - const mobileSeparatorStyles = await taxonomy - .locator(".skill-category-meta-link, .skill-hero-topic") - .evaluateAll((elements) => - elements.map((element) => { - const style = getComputedStyle(element, "::before"); - return { - backgroundColor: style.backgroundColor, - content: style.content, - height: style.height, - width: style.width, - }; - }), - ); - expect(mobileSeparatorStyles.length).toBeGreaterThanOrEqual(2); - expect( - mobileSeparatorStyles.every( - (style) => - style.content !== "none" && - style.content !== "normal" && - style.width === "1px" && - style.height === "14px" && - style.backgroundColor !== "rgba(0, 0, 0, 0)", - ), - ).toBe(true); - - await category.evaluate((categoryTemplate) => { - const list = categoryTemplate.parentElement; - if (!list) throw new Error("Category list is missing"); - const labels = ["Communication", "Productivity", "Development"]; - labels.forEach((label, index) => { - const categoryItem = index === 0 ? categoryTemplate : categoryTemplate.cloneNode(true); - if (!(categoryItem instanceof HTMLAnchorElement)) { - throw new Error("Category template is incomplete"); - } - const labelElement = categoryItem.querySelector("span:last-child"); - if (!labelElement) throw new Error("Category label is missing"); - categoryItem.href = `/skills?category=${label.toLowerCase()}`; - categoryItem.setAttribute("aria-label", `View ${label} skills`); - labelElement.textContent = label; - if (index > 0) list.append(categoryItem); - }); - }); - await expect(categories).toHaveCount(3); - - await topics.first().evaluate((topicTemplate) => { - const list = topicTemplate.parentElement; - if (!list) throw new Error("Topic list is missing"); - const labels = ["#video-generation", "#creative-production", "#automation-workflows"]; - labels.forEach((label, index) => { - const topicItem = index === 0 ? topicTemplate : topicTemplate.cloneNode(true); - if (!(topicItem instanceof HTMLAnchorElement)) { - throw new Error("Topic template is incomplete"); - } - topicItem.href = `/topics/${label.slice(1)}`; - topicItem.setAttribute("aria-label", `View skills tagged ${label}`); - topicItem.textContent = label; - if (index > 0) list.append(topicItem); - }); - }); - await expect(topics).toHaveCount(3); - - await page.setViewportSize({ width: 320, height: 900 }); - const wrappedGroupTops = await Promise.all( - [source, category, topics.first()].map(async (locator) => (await locator.boundingBox())?.y), - ); - expect(wrappedGroupTops.every((top) => top !== undefined)).toBe(true); - expect( - Math.max(...(wrappedGroupTops as number[])) - Math.min(...(wrappedGroupTops as number[])), - ).toBeGreaterThan(1); - - const categoryTops = await categories.evaluateAll((elements) => - elements.map((element) => Math.round(element.getBoundingClientRect().top)), - ); - expect(new Set(categoryTops).size).toBeGreaterThan(1); - - const topicTops = await topics.evaluateAll((elements) => - elements.map((element) => Math.round(element.getBoundingClientRect().top)), - ); - expect(new Set(topicTops).size).toBeGreaterThan(1); - - const semanticUnitStyles = await taxonomy - .locator(".skills-sh-sync-source-label, .skill-category-meta-link, .skill-hero-topic") - .evaluateAll((elements) => - elements.map((element) => ({ - rects: element.getClientRects().length, - whiteSpace: getComputedStyle(element).whiteSpace, - })), - ); - expect( - semanticUnitStyles.every((style) => style.rects === 1 && style.whiteSpace === "nowrap"), - ).toBe(true); - - const overflow = await page.evaluate(() => ({ - clientWidth: document.documentElement.clientWidth, - scrollWidth: document.documentElement.scrollWidth, - })); - expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 1); - await expectHealthyPage(page, errors); -}); - test("plugin hero metadata retains its full-width mobile topic row", async ({ page }) => { const errors = trackRuntimeErrors(page); await page.route("**/_vercel/image?**", (route) => route.fulfill({ status: 204 })); diff --git a/specs/ci.md b/specs/ci.md index 79ca1b3f2b..6a86320201 100644 --- a/specs/ci.md +++ b/specs/ci.md @@ -64,6 +64,8 @@ To run one authenticated local browser spec through the same infra: bun run test:pw:local-auth -- --project=chromium e2e/local-auth/.pw.test.ts ``` +The skill hero layout regression runs in the local-auth profile/context shard against the seeded skills.sh fixture. Public smoke tests must not rely on a particular third-party listing retaining its visibility or taxonomy. + The local-auth runner supports Linux and macOS and uses dev auth and a local Convex deployment; it does not need production credentials or a ClawHub auth token. It starts its own isolated local Convex process and temporarily moves aside `.env.local` plus