diff --git a/.changeset/visual-contracts-tooltip.md b/.changeset/visual-contracts-tooltip.md new file mode 100644 index 000000000..fd3fa8beb --- /dev/null +++ b/.changeset/visual-contracts-tooltip.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/kumo": patch +--- + +Add a visual regression contract for truncating text in default Tooltip triggers. diff --git a/.github/workflows/pullrequest-report.yml b/.github/workflows/pullrequest-report.yml index e56273722..6d5703a71 100644 --- a/.github/workflows/pullrequest-report.yml +++ b/.github/workflows/pullrequest-report.yml @@ -21,6 +21,54 @@ concurrency: cancel-in-progress: true jobs: + visual-contracts: + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Resolve PR metadata + id: metadata + uses: actions/github-script@v7 + with: + script: | + const prs = context.payload.workflow_run.pull_requests; + if (prs && prs.length > 0) { + core.setOutput('pr_number', prs[0].number.toString()); + return; + } + + const run = context.payload.workflow_run; + const owner = run.head_repository.full_name.split('/')[0]; + const { data: pulls } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + head: `${owner}:${run.head_branch}`, + }); + const match = pulls.find(pr => pr.head.sha === run.head_sha); + if (!match) { + core.setFailed(`No open PR found for workflow run ${run.id}`); + return; + } + core.setOutput('pr_number', match.number.toString()); + + - name: Checkout trusted reporter + uses: actions/checkout@v4 + with: + ref: main + + - name: Install trusted reporter dependencies + uses: ./.github/actions/install-dependencies + with: + filter: kumo-workspace + + - name: Post visual-contract gallery + env: + GITHUB_PR_NUMBER: ${{ steps.metadata.outputs.pr_number }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VISUAL_CONTRACTS_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: vp exec tsx ci/scripts/post-visual-contracts-report.ts + bundle-size: if: >- github.event.workflow_run.event == 'pull_request' && diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 2414f6c78..d37047011 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -133,6 +133,30 @@ jobs: - run: vp run --filter @cloudflare/kumo test - run: vp run test:ci + visual-contracts: + timeout-minutes: 5 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + - uses: ./.github/actions/install-dependencies + with: + filter: "@cloudflare/kumo" + - name: Install Chromium + working-directory: packages/kumo + run: vp exec playwright install --with-deps chromium + - name: Run visual contracts + run: vp run --filter @cloudflare/kumo test:visual-contracts + - name: Upload visual-contract artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: visual-contract-artifacts + path: packages/kumo/.vitest-attachments/ + if-no-files-found: ignore + retention-days: 7 + test-react-compatibility: needs: build timeout-minutes: 5 diff --git a/.gitignore b/.gitignore index a311bed12..90be4a313 100644 --- a/.gitignore +++ b/.gitignore @@ -64,5 +64,10 @@ packages/kumo/ai/schemas.ts .vitest-attachments/ __screenshots__/ +# Browser visual-contract baselines are intentional test fixtures. Keep them +# alongside their component tests so snapshot updates are visible in review. +!packages/kumo/src/components/**/__screenshots__/ +!packages/kumo/src/components/**/__screenshots__/** + # CI report artifacts ci/reports/ diff --git a/ci/scripts/post-visual-contracts-report.ts b/ci/scripts/post-visual-contracts-report.ts new file mode 100644 index 000000000..7bc5bbc1a --- /dev/null +++ b/ci/scripts/post-visual-contracts-report.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env tsx + +/** + * Publish the curated visual-contract gallery for a pull request. + * + * This runs from the trusted default-branch checkout after the untrusted PR + * workflow completes. Screenshot paths come from GitHub's tree API, and only + * PNGs beneath Kumo component screenshot directories are rendered. + */ + +import { Octokit } from "@octokit/rest"; +import { + GITHUB_REPO_NAME, + GITHUB_REPO_OWNER, + upsertPRComment, +} from "../utils/github-api"; + +const COMMENT_MARKER = ""; +const SCREENSHOT_PATH = + /^packages\/kumo\/src\/components\/.+\/__screenshots__\/.+\.png$/; + +function displayName(path: string): string { + return ( + path + .split("/") + .at(-1) + ?.replace(/-chromium-(darwin|linux)\.png$/, "") + .replace(/\.png$/, "") + .replaceAll("-", " ") ?? path + ); +} + +async function main(): Promise { + const token = process.env.GITHUB_TOKEN ?? ""; + const prNumber = Number(process.env.GITHUB_PR_NUMBER); + const headSha = process.env.VISUAL_CONTRACTS_HEAD_SHA ?? ""; + + if (!token || !Number.isInteger(prNumber) || prNumber <= 0 || !headSha) { + throw new Error( + "GITHUB_TOKEN, GITHUB_PR_NUMBER, and VISUAL_CONTRACTS_HEAD_SHA are required", + ); + } + + const octokit = new Octokit({ auth: token }); + const { data } = await octokit.git.getTree({ + owner: GITHUB_REPO_OWNER, + repo: GITHUB_REPO_NAME, + tree_sha: headSha, + recursive: "true", + }); + const screenshots = data.tree + .filter( + (entry) => + entry.type === "blob" && SCREENSHOT_PATH.test(entry.path ?? ""), + ) + .map((entry) => entry.path as string) + .filter((path) => path.endsWith("-chromium-linux.png")) + .sort(); + + if (screenshots.length === 0) { + console.log( + "No Linux visual-contract screenshots found; skipping PR comment", + ); + return; + } + + const gallery = screenshots + .map((path) => { + const url = `https://github.com/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/blob/${headSha}/${path}?raw=true`; + return `#### ${displayName(path)}\n\n![${displayName(path)}](${url})`; + }) + .join("\n\n"); + const content = [ + "## Visual contracts", + "", + "Linux Chromium references exercised by the PR's `visual-contracts` job.", + "", + "
", + `${screenshots.length} curated contract${screenshots.length === 1 ? "" : "s"}`, + "", + gallery, + "", + "
", + ].join("\n"); + + await upsertPRComment(token, prNumber, COMMENT_MARKER, content); + console.log(`Visual-contract gallery posted to PR #${prNumber}`); +} + +main().catch((error) => { + console.error("Failed to post visual-contract gallery:", error); + process.exit(1); +}); diff --git a/packages/kumo/package.json b/packages/kumo/package.json index f335ea12d..aac0c2733 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -462,7 +462,8 @@ "typecheck": "tsc --noEmit", "validate:build": "vp test run --project=unit tests/imports/export-path-validation.test.ts", "validate:changeset": "tsx ../../ci/scripts/validate-kumo-changeset.ts", - "test:browser": "vp test --config=vitest.browser.config.ts" + "test:browser": "vp test --config=vitest.browser.config.ts", + "test:visual-contracts": "vp test run --config=vitest.visual-contracts.config.ts" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png new file mode 100644 index 000000000..e50867fa8 Binary files /dev/null and b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-darwin.png differ diff --git a/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png new file mode 100644 index 000000000..96317aa79 Binary files /dev/null and b/packages/kumo/src/components/tooltip/__screenshots__/tooltip.visual.browser.test.tsx/truncating-text-trigger-chromium-linux.png differ diff --git a/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx b/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx new file mode 100644 index 000000000..8abbdfe27 --- /dev/null +++ b/packages/kumo/src/components/tooltip/tooltip.visual.browser.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { Text } from "../text/text"; +import { Tooltip } from "./tooltip"; + +describe("Tooltip visual contracts", () => { + test("keeps a truncating Text trigger visible", async () => { + const { getByTestId } = await render( +
+ + + 192.0.2.0/24 + + +
, + ); + + await expect + .element(getByTestId("tooltip-text-trigger")) + .toMatchScreenshot("truncating-text-trigger"); + }); +}); diff --git a/packages/kumo/vitest.visual-contracts.config.ts b/packages/kumo/vitest.visual-contracts.config.ts new file mode 100644 index 000000000..23dc7f786 --- /dev/null +++ b/packages/kumo/vitest.visual-contracts.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite-plus"; +import { playwright } from "vite-plus/test/browser-playwright"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + test: { + include: ["**/*.visual.browser.test.tsx"], + setupFiles: ["./tests/setup-browser.css"], + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + screenshotFailures: true, + }, + testTimeout: 2_000, + }, +});