From 1d6caf663a1d2f6d272e4999d8a8572987cd2713 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Mon, 7 Sep 2026 02:15:08 +0000 Subject: [PATCH 1/2] Fix audit tabs blocked by the Export dropdown (upstream #223) Closes #8 --- e2e/domain-overview-filters.spec.ts | 28 ++++ .../components/table/TableBulkActionBar.tsx | 129 +++++++++++++++--- .../features/audit/results/ResultsTables.tsx | 2 +- 3 files changed, 139 insertions(+), 20 deletions(-) diff --git a/e2e/domain-overview-filters.spec.ts b/e2e/domain-overview-filters.spec.ts index a41cf7605..8bf87701d 100644 --- a/e2e/domain-overview-filters.spec.ts +++ b/e2e/domain-overview-filters.spec.ts @@ -12,6 +12,34 @@ import { } from "./domain-overview-test-utils"; test.describe("Domain Overview filters", () => { + test("export menu dismisses without swallowing the next tab click", async ({ + page, + }) => { + await openDomainOverview(page, "keywords"); + + const exportButton = page.getByRole("button", { name: "Export" }); + await exportButton.click(); + await expect(page.getByRole("menu")).toBeVisible(); + + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "Download CSV" }).click(); + await download; + await expect(page.getByRole("menu")).toHaveCount(0); + + await exportButton.click(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(exportButton).toBeFocused(); + + await exportButton.click(); + await page.getByRole("tab", { name: "Top Pages" }).click(); + await expect(page.getByRole("tab", { name: "Top Pages" })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(page.getByRole("menu")).toHaveCount(0); + }); + test("closing an inactive search tab does not select it", async ({ page, }) => { diff --git a/src/client/components/table/TableBulkActionBar.tsx b/src/client/components/table/TableBulkActionBar.tsx index f6e1e10c2..22c2190ba 100644 --- a/src/client/components/table/TableBulkActionBar.tsx +++ b/src/client/components/table/TableBulkActionBar.tsx @@ -1,5 +1,5 @@ import { ChevronDown, Download, Loader2, X } from "lucide-react"; -import type { ReactNode } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; export function TableBulkActionBar({ selectedCount, @@ -131,7 +131,7 @@ export function TableBulkExportMenu({ export function TableExportMenu({ actions, buttonClassName = "btn btn-sm gap-1", - menuClassName = "dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56", + menuClassName = "z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-56", }: { actions: Array<{ label: ReactNode; @@ -142,27 +142,118 @@ export function TableExportMenu({ buttonClassName?: string; menuClassName?: string; }) { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const containerRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + const closeOnOutsidePointer = (event: PointerEvent) => { + if ( + event.target instanceof Node && + !containerRef.current?.contains(event.target) + ) { + setIsOpen(false); + } + }; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + setIsOpen(false); + triggerRef.current?.focus(); + }; + const closeOnOutsideFocus = (event: FocusEvent) => { + if ( + event.target instanceof Node && + !containerRef.current?.contains(event.target) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("pointerdown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + document.addEventListener("focusin", closeOnOutsideFocus); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + document.removeEventListener("focusin", closeOnOutsideFocus); + }; + }, [isOpen]); + return ( -
-
+
+
-
    - {actions.map((action, index) => ( -
  • - -
  • - ))} -
+ + {isOpen ? ( + + ) : null}
); } diff --git a/src/client/features/audit/results/ResultsTables.tsx b/src/client/features/audit/results/ResultsTables.tsx index ddee9b21f..8c12381f5 100644 --- a/src/client/features/audit/results/ResultsTables.tsx +++ b/src/client/features/audit/results/ResultsTables.tsx @@ -249,7 +249,7 @@ export function ExportDropdown({ return ( onExport("sheets") }, { label: "CSV", onClick: () => onExport("csv") }, From f19dde999a7937222705e0e4eb4d132dc4331a68 Mon Sep 17 00:00:00 2001 From: "factorychief[bot]" Date: Mon, 7 Sep 2026 02:22:21 +0000 Subject: [PATCH 2/2] Fix audit tabs blocked by the Export dropdown (upstream #223) Closes #8 --- e2e/audit-results-export.spec.ts | 47 +++++++++++ e2e/domain-overview-filters.spec.ts | 28 ------- e2e/fixtures/audit-results-fixtures.ts | 103 +++++++++++++++++++++++++ playwright.config.ts | 2 +- src/serverFunctions/audit.ts | 16 ++++ 5 files changed, 167 insertions(+), 29 deletions(-) create mode 100644 e2e/audit-results-export.spec.ts create mode 100644 e2e/fixtures/audit-results-fixtures.ts diff --git a/e2e/audit-results-export.spec.ts b/e2e/audit-results-export.spec.ts new file mode 100644 index 000000000..837e29fe6 --- /dev/null +++ b/e2e/audit-results-export.spec.ts @@ -0,0 +1,47 @@ +import { expect, test, type Page } from "@playwright/test"; + +async function openCompletedAudit(page: Page) { + await page.goto("/"); + await page.waitForURL(/\/p\/([^/]+)\/?$/, { timeout: 30_000 }); + const projectId = page.url().match(/\/p\/([^/]+)/)?.[1]; + if (!projectId) + throw new Error(`Could not read project id from ${page.url()}`); + + await page.goto(`/p/${projectId}/audit?auditId=audit-results-e2e`); + await expect(page.getByRole("tab", { name: "Issues (1)" })).toBeVisible(); +} + +test("audit Export menu does not block result tabs or dismissal", async ({ + page, +}) => { + await openCompletedAudit(page); + + const exportButton = page.getByRole("button", { name: "Export" }); + const assertTabSelectsThroughOpenMenu = async (name: string) => { + await exportButton.click(); + await expect(page.getByRole("menu")).toBeVisible(); + const tab = page.getByRole("tab", { name }); + await tab.click(); + await expect(tab).toHaveAttribute("aria-selected", "true"); + await expect(page.getByRole("menu")).toHaveCount(0); + }; + + await assertTabSelectsThroughOpenMenu("Pages (1)"); + await assertTabSelectsThroughOpenMenu("Performance (1)"); + await assertTabSelectsThroughOpenMenu("Issues (1)"); + + await exportButton.click(); + const download = page.waitForEvent("download"); + await page.getByRole("menuitem", { name: "CSV" }).click(); + await download; + await expect(page.getByRole("menu")).toHaveCount(0); + + await exportButton.click(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu")).toHaveCount(0); + await expect(exportButton).toBeFocused(); + + await exportButton.click(); + await page.getByRole("heading", { name: "audit.example.com" }).click(); + await expect(page.getByRole("menu")).toHaveCount(0); +}); diff --git a/e2e/domain-overview-filters.spec.ts b/e2e/domain-overview-filters.spec.ts index 8bf87701d..a41cf7605 100644 --- a/e2e/domain-overview-filters.spec.ts +++ b/e2e/domain-overview-filters.spec.ts @@ -12,34 +12,6 @@ import { } from "./domain-overview-test-utils"; test.describe("Domain Overview filters", () => { - test("export menu dismisses without swallowing the next tab click", async ({ - page, - }) => { - await openDomainOverview(page, "keywords"); - - const exportButton = page.getByRole("button", { name: "Export" }); - await exportButton.click(); - await expect(page.getByRole("menu")).toBeVisible(); - - const download = page.waitForEvent("download"); - await page.getByRole("menuitem", { name: "Download CSV" }).click(); - await download; - await expect(page.getByRole("menu")).toHaveCount(0); - - await exportButton.click(); - await page.keyboard.press("Escape"); - await expect(page.getByRole("menu")).toHaveCount(0); - await expect(exportButton).toBeFocused(); - - await exportButton.click(); - await page.getByRole("tab", { name: "Top Pages" }).click(); - await expect(page.getByRole("tab", { name: "Top Pages" })).toHaveAttribute( - "aria-selected", - "true", - ); - await expect(page.getByRole("menu")).toHaveCount(0); - }); - test("closing an inactive search tab does not select it", async ({ page, }) => { diff --git a/e2e/fixtures/audit-results-fixtures.ts b/e2e/fixtures/audit-results-fixtures.ts new file mode 100644 index 000000000..12ab66cd4 --- /dev/null +++ b/e2e/fixtures/audit-results-fixtures.ts @@ -0,0 +1,103 @@ +const startedAt = "2026-09-07T01:00:00.000Z"; +const completedAt = "2026-09-07T01:01:00.000Z"; + +export function getAuditStatusFixture(auditId: string) { + return { + id: auditId, + startUrl: "https://audit.example.com/", + status: "completed" as const, + pagesCrawled: 2, + pagesTotal: 2, + lighthouseTotal: 1, + lighthouseCompleted: 1, + lighthouseFailed: 0, + currentPhase: "completed", + errorCode: null, + startedAt, + completedAt, + }; +} + +export function getAuditResultsFixture(auditId: string) { + const page = { + id: "audit-page-1", + auditId, + url: "https://audit.example.com/", + statusCode: 200, + redirectUrl: null, + title: "Audit fixture", + metaDescription: "Fixture page for audit result interactions", + canonicalUrl: "https://audit.example.com/", + robotsMeta: null, + ogTitle: null, + ogDescription: null, + ogImage: null, + h1Count: 1, + h2Count: 0, + h3Count: 0, + h4Count: 0, + h5Count: 0, + h6Count: 0, + headingOrderJson: '[{"level":1,"text":"Audit fixture"}]', + wordCount: 120, + imagesTotal: 0, + imagesMissingAlt: 0, + imagesJson: "[]", + internalLinkCount: 1, + externalLinkCount: 0, + hasStructuredData: false, + hreflangTagsJson: "[]", + isIndexable: true, + xRobotsTag: null, + headerCanonicalUrl: null, + crawlDepth: 0, + inSitemap: true, + contentHash: "fixture-content-hash", + fetchClass: "ok" as const, + responseTimeMs: 80, + }; + + return { + audit: { + id: auditId, + startUrl: "https://audit.example.com/", + status: "completed" as const, + pagesCrawled: 2, + pagesTotal: 2, + startedAt, + completedAt, + config: { maxPages: 10, lighthouseStrategy: "mobile" as const }, + }, + pages: [page], + lighthouse: [ + { + id: "lighthouse-1", + auditId, + pageId: page.id, + strategy: "mobile" as const, + performanceScore: 92, + accessibilityScore: 98, + bestPracticesScore: 96, + seoScore: 100, + lcpMs: 1200, + cls: 0.02, + inpMs: 90, + ttfbMs: 180, + errorMessage: null, + r2Key: null, + payloadSizeBytes: null, + }, + ], + issues: [ + { + id: "issue-1", + auditId, + pageId: page.id, + pageUrl: page.url, + issueType: "missing-meta-description", + severity: "warning" as const, + detailsJson: null, + }, + ], + }; +} diff --git a/playwright.config.ts b/playwright.config.ts index 5b0c3e186..5ace5c7fd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ }, webServer: { command: - "NODE_OPTIONS= AUTH_MODE=local_noauth VITE_E2E_DOMAIN_FIXTURES=1 VITE_E2E_KEYWORD_FIXTURES=1 PORT=3101 pnpm exec vite dev --host 127.0.0.1 --strictPort", + "NODE_OPTIONS= AUTH_MODE=local_noauth VITE_E2E_AUDIT_FIXTURES=1 VITE_E2E_DOMAIN_FIXTURES=1 VITE_E2E_KEYWORD_FIXTURES=1 PORT=3101 pnpm exec vite dev --host 127.0.0.1 --strictPort", url: "http://localhost:3101", reuseExistingServer: false, timeout: 120_000, diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index afe5a28a6..b6b8bfb0c 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -13,6 +13,14 @@ import { startAuditSchema, } from "@/types/schemas/audit"; +function useAuditE2eFixtures() { + return import.meta.env.VITE_E2E_AUDIT_FIXTURES === "1"; +} + +function getAuditE2eFixtures() { + return import("../../e2e/fixtures/audit-results-fixtures"); +} + export const startAudit = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .validator(startAuditSchema) @@ -50,6 +58,10 @@ export const getAuditStatus = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .validator(getAuditStatusSchema) .handler(async ({ data, context }) => { + if (useAuditE2eFixtures()) { + const fixtures = await getAuditE2eFixtures(); + return fixtures.getAuditStatusFixture(data.auditId); + } return AuditService.getStatus(data.auditId, context.projectId); }); @@ -57,6 +69,10 @@ export const getAuditResults = createServerFn({ method: "POST" }) .middleware(requireProjectContext) .validator(getAuditResultsSchema) .handler(async ({ data, context }) => { + if (useAuditE2eFixtures()) { + const fixtures = await getAuditE2eFixtures(); + return fixtures.getAuditResultsFixture(data.auditId); + } return AuditService.getResults(data.auditId, context.projectId); });