Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions e2e/audit-results-export.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
103 changes: 103 additions & 0 deletions e2e/fixtures/audit-results-fixtures.ts
Original file line number Diff line number Diff line change
@@ -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,
},
],
};
}
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
129 changes: 110 additions & 19 deletions src/client/components/table/TableBulkActionBar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -142,27 +142,118 @@ export function TableExportMenu({
buttonClassName?: string;
menuClassName?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const menuId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(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 (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className={buttonClassName}>
<div ref={containerRef} className="relative">
<button
ref={triggerRef}
type="button"
className={buttonClassName}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-controls={isOpen ? menuId : undefined}
onClick={() => setIsOpen((open) => !open)}
onKeyDown={(event) => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
setIsOpen(true);
requestAnimationFrame(() => {
containerRef.current
?.querySelector<HTMLButtonElement>("ul button:not(:disabled)")
?.focus();
});
}}
>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul tabIndex={0} className={menuClassName}>
{actions.map((action, index) => (
<li key={index}>
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
>
{action.icon}
{action.label}
</button>
</li>
))}
</ul>
</button>
{isOpen ? (
<ul
id={menuId}
role="menu"
className={`${menuClassName} absolute top-full right-0 mt-2`}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
return;
}
event.preventDefault();
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
"button:not(:disabled)",
),
);
const current = items.findIndex(
(item) => item === document.activeElement,
);
const next =
event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: event.key === "ArrowDown"
? (current + 1) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
}}
>
{actions.map((action, index) => (
<li key={index} role="none">
<button
type="button"
role="menuitem"
onClick={() => {
setIsOpen(false);
action.onClick();
}}
disabled={action.disabled}
>
{action.icon}
{action.label}
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
2 changes: 1 addition & 1 deletion src/client/features/audit/results/ResultsTables.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export function ExportDropdown({
return (
<TableExportMenu
buttonClassName="btn btn-sm btn-ghost gap-1"
menuClassName="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
menuClassName="z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-52"
actions={[
{ label: "Export to Sheets", onClick: () => onExport("sheets") },
{ label: "CSV", onClick: () => onExport("csv") },
Expand Down
16 changes: 16 additions & 0 deletions src/serverFunctions/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -50,13 +58,21 @@ 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);
});

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);
});

Expand Down