diff --git a/.trae/TODO.md b/.trae/TODO.md new file mode 100644 index 00000000..b9fdd041 --- /dev/null +++ b/.trae/TODO.md @@ -0,0 +1,14 @@ +# TODO: + +- [x] 1: Update bundler.ts to handle RSLint instead of Rspack/Turbopack (priority: High) +- [x] 2: Update data.tsx to use RSLint-specific KV keys and data structure (priority: High) +- [x] 7: Set up comprehensive end-to-end testing framework (priority: High) +- [x] 8: Create mock KV data and test utilities for RSLint test results (priority: High) +- [x] 3: Update HeatMap components to display RSLint test results with appropriate GitHub links (priority: Medium) +- [x] 4: Update UI text and branding throughout the app to reflect RSLint instead of bundlers (priority: Medium) +- [x] 6: Test the updated implementation (priority: Medium) +- [x] 9: Add tests for data fetching and processing functions (priority: Medium) +- [x] 10: Create tests for HeatMap rendering with RSLint test results (priority: Medium) +- [x] 5: Update page titles and metadata to reflect RSLint tracking (priority: Low) +- [ ] 11: Run end-to-end tests with Playwright to verify complete application functionality (**IN PROGRESS**) (priority: High) +- [ ] 12: Verify the application works correctly in the browser (priority: Medium) diff --git a/arewerslintyet/.gitignore b/arewerslintyet/.gitignore new file mode 100644 index 00000000..d7be0e1b --- /dev/null +++ b/arewerslintyet/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +/coverage + +# next.js +.next/ +out/ + +# production +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# playwright +playwright-report/ +test-results/ +/playwright-report +/test-results diff --git a/arewerslintyet/.npmrc b/arewerslintyet/.npmrc new file mode 100644 index 00000000..3e775efb --- /dev/null +++ b/arewerslintyet/.npmrc @@ -0,0 +1 @@ +auto-install-peers=true diff --git a/arewerslintyet/README.md b/arewerslintyet/README.md new file mode 100644 index 00000000..feffa701 --- /dev/null +++ b/arewerslintyet/README.md @@ -0,0 +1,9 @@ +# Are We Turbo Yet? + +This site tracks test passing for Turbopack inside of Next.js. + +## Development + +1. Clone the repository +1. `vercel link` +1. `vercel env pull .env.local` diff --git a/arewerslintyet/app/Footer.js b/arewerslintyet/app/Footer.js new file mode 100644 index 00000000..983c4a76 --- /dev/null +++ b/arewerslintyet/app/Footer.js @@ -0,0 +1,43 @@ +import { Bundler, getBundler } from './bundler'; + +function FooterLink({ href, children }) { + return ( + + {children} + + ); +} + +export default function Footer() { + const bundler = getBundler(); + return ( +
+ {bundler === Bundler.Turbopack ? ( + <> + + Turbopack Docs + + · + + Next.js 15 + + + ) : ( + <> + Rspack Docs + · + Next.js Docs + · + + Are We Turbo Yet? + + + )} +
+ ); +} diff --git a/arewerslintyet/app/Geist-Regular.woff2 b/arewerslintyet/app/Geist-Regular.woff2 new file mode 100644 index 00000000..f03a99e0 Binary files /dev/null and b/arewerslintyet/app/Geist-Regular.woff2 differ diff --git a/arewerslintyet/app/Geist-SemiBold.woff2 b/arewerslintyet/app/Geist-SemiBold.woff2 new file mode 100644 index 00000000..33704bc9 Binary files /dev/null and b/arewerslintyet/app/Geist-SemiBold.woff2 differ diff --git a/arewerslintyet/app/Graph.tsx b/arewerslintyet/app/Graph.tsx new file mode 100644 index 00000000..a9ce011e --- /dev/null +++ b/arewerslintyet/app/Graph.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; + +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; + +const CHART_CONFIG = { + percentPassing: { + color: 'hsl(var(--chart))', + }, +} satisfies ChartConfig; + +const DATE_FORMAT = new Intl.DateTimeFormat(undefined, { + month: 'short', + year: 'numeric', +}); + +function tooltipFormatter(_value, _name, item) { + let data = item.payload; + let gitHash = data.gitHash.slice(0, 7); + let progress = `${data.passing} / ${data.total}`; + return ( + <> +

{`${new Date(data.date).toLocaleString('default', { + year: 'numeric', + month: 'long', + day: 'numeric', + })}\n→ ${gitHash}`}

+

{`${data.percent}% (${progress})`}

+ + ); +} + +function formatDateTick(dateSinceEpoch: number) { + return DATE_FORMAT.format(dateSinceEpoch); +} + +export default function TestPassingGraph({ graphData }) { + // compute this manually, otherwise AreaChart will always start at zero + const minPercent = Math.floor(Math.min(...graphData.map(d => d.percent))); + return ( +
+ + + + + + } + /> + {/* Ideally the fill would use a linear gradient like shadcn/ui's + example charts do, but that doesn't play well with allowDataOverflow, + so just use a solid color instead. */} + + + +
+ ); +} diff --git a/arewerslintyet/app/GraphDataDevelopment.tsx b/arewerslintyet/app/GraphDataDevelopment.tsx new file mode 100644 index 00000000..a7941079 --- /dev/null +++ b/arewerslintyet/app/GraphDataDevelopment.tsx @@ -0,0 +1,8 @@ +import { getDevelopmentLintRuns } from './data'; +import Graph from './Graph'; + +export default async function GraphDataDevelopment() { + const { graphData } = await getDevelopmentLintRuns(); + + return ; +} diff --git a/arewerslintyet/app/GraphDataProduction.tsx b/arewerslintyet/app/GraphDataProduction.tsx new file mode 100644 index 00000000..727fd7b5 --- /dev/null +++ b/arewerslintyet/app/GraphDataProduction.tsx @@ -0,0 +1,11 @@ +import { getProductionLintRuns } from './data'; +import Graph from './Graph'; + +export default async function GraphDataProduction() { + const { graphData } = await getProductionLintRuns(); + if (graphData.length === 0) { + return null; + } + + return ; +} diff --git a/arewerslintyet/app/HeatMap.tsx b/arewerslintyet/app/HeatMap.tsx new file mode 100644 index 00000000..09436646 --- /dev/null +++ b/arewerslintyet/app/HeatMap.tsx @@ -0,0 +1,47 @@ +import HeatMapItem from './HeatMapItem'; + +function getTooltipContent(data) { + let ruleName = data.test.slice(2); + return `rule: "${ruleName}"`; +} + +export function HeapMap({ lintResults }) { + let index = 0; + let testData = {}; + + Object.keys(lintResults).forEach(status => { + const value = lintResults[status]; + if (!value) return; + value.split('\n\n').forEach(ruleGroup => { + let lines = ruleGroup.replace(/\n$/, '').split('\n'); + let file = lines[0]; + let rules = lines.slice(1); + if (!testData[file]) { + testData[file] = {}; + } + testData[file][status] = rules.map(test => { + const tooltipContent = getTooltipContent({ file, test }); + return ( + + ); + }); + }); + }); + + let items = []; + Object.keys(testData).forEach(file => { + let testList = testData[file]; + items = items.concat( + Object.keys(testList).map(status => { + return testList[status]; + }), + ); + }); + + return <>{items}; +} diff --git a/arewerslintyet/app/HeatMapDevelopment.tsx b/arewerslintyet/app/HeatMapDevelopment.tsx new file mode 100644 index 00000000..f1528b7f --- /dev/null +++ b/arewerslintyet/app/HeatMapDevelopment.tsx @@ -0,0 +1,16 @@ +import { getDevelopmentLintResults } from './data'; +import { HeapMap } from './HeatMap'; + +export default async function HeatMapDevelopment() { + const data = await getDevelopmentLintResults(); + + if (!data) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/arewerslintyet/app/HeatMapExamples.tsx b/arewerslintyet/app/HeatMapExamples.tsx new file mode 100644 index 00000000..94caaf79 --- /dev/null +++ b/arewerslintyet/app/HeatMapExamples.tsx @@ -0,0 +1,30 @@ +import { getRuleExamplesResults } from './data'; +import HeatMapItem from './HeatMapItem'; + +export async function HeapMapExamples() { + const ruleExamplesResult = await getRuleExamplesResults(); + + if (!ruleExamplesResult || Object.keys(ruleExamplesResult).length === 0) { + return null; + } + + let items = []; + for (const ruleName in ruleExamplesResult) { + const isPassing = ruleExamplesResult[ruleName]; + items.push( + , + ); + } + + return ( + <> +

Rule Examples

+
{items}
+ + ); +} diff --git a/arewerslintyet/app/HeatMapItem.tsx b/arewerslintyet/app/HeatMapItem.tsx new file mode 100644 index 00000000..79041a41 --- /dev/null +++ b/arewerslintyet/app/HeatMapItem.tsx @@ -0,0 +1,31 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { twJoin } from 'tailwind-merge'; +import { useTooltip } from './TooltipContext'; + +function HeatMapItem({ tooltipContent, href, isPassing }) { + const { onMouseOver, onMouseOut } = useTooltip(); + const handleMouseOver = event => { + onMouseOver(event, tooltipContent, isPassing ? 'passing' : 'failing'); + }; + + return ( + // biome-ignore lint/a11y/useAnchorContent: aria-label is sufficient + + ); +} + +export default React.memo(HeatMapItem); diff --git a/arewerslintyet/app/HeatMapProduction.tsx b/arewerslintyet/app/HeatMapProduction.tsx new file mode 100644 index 00000000..d26b6702 --- /dev/null +++ b/arewerslintyet/app/HeatMapProduction.tsx @@ -0,0 +1,16 @@ +import { getProductionLintResults } from './data'; +import { HeapMap } from './HeatMap'; + +export default async function HeatMapProduction() { + const data = await getProductionLintResults(); + + if (!data) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/arewerslintyet/app/IsItReady.tsx b/arewerslintyet/app/IsItReady.tsx new file mode 100644 index 00000000..8a1dc8b7 --- /dev/null +++ b/arewerslintyet/app/IsItReady.tsx @@ -0,0 +1,64 @@ +import { Bundler, getBundler } from './bundler'; + +interface Props { + title: string; + description: string; + percent: number; + decision?: 'yes' | 'no' | null; +} + +const RSPACK_WARNING = ( +

+ next-rspack is currently experimental. It's not an official Next.js plugin, + and is supported by the Rspack team in partnership with Next.js. Help + improve Next.js and Rspack{' '} + + by providing feedback + + . +

+); + +export default function IsItReady({ + title, + description, + percent, + decision: forcedDecision, +}: Props) { + const decision = + forcedDecision === 'yes' + ? true + : forcedDecision === 'no' + ? false + : percent === 100; + + return ( +
+ {decision ? ( +

+ {title}: YES + + {'\ud83c\udf89'} + +

+ ) : ( + <> +

+ {title}: NO +

+

+ {percent}% of Next.js{' '} + {description} are passing though + + {'\u2705'} + +

+ + )} + {getBundler() === Bundler.Rspack && RSPACK_WARNING} +
+ ); +} diff --git a/arewerslintyet/app/IsItReadyDevelopment.tsx b/arewerslintyet/app/IsItReadyDevelopment.tsx new file mode 100644 index 00000000..b53718b2 --- /dev/null +++ b/arewerslintyet/app/IsItReadyDevelopment.tsx @@ -0,0 +1,20 @@ +import { Linter, getLinter } from './bundler'; +import { getDevelopmentLintRuns } from './data'; +import IsItReady from './IsItReady'; + +export default async function IsItReadyDevelopment() { + const { mostRecent } = await getDevelopmentLintRuns(); + + if (!mostRecent) { + return null; + } + + return ( + = 90 ? 'yes' : 'no'} + /> + ); +} diff --git a/arewerslintyet/app/IsitReadyProduction.tsx b/arewerslintyet/app/IsitReadyProduction.tsx new file mode 100644 index 00000000..73f8371b --- /dev/null +++ b/arewerslintyet/app/IsitReadyProduction.tsx @@ -0,0 +1,20 @@ +import { Linter, getLinter } from './bundler'; +import { getProductionLintRuns } from './data'; +import IsItReady from './IsItReady'; + +export default async function IsItReadyProduction() { + const { mostRecent } = await getProductionLintRuns(); + + if (!mostRecent) { + return null; + } + + return ( + = 90 ? 'yes' : 'no'} + /> + ); +} diff --git a/arewerslintyet/app/ProgressBar.tsx b/arewerslintyet/app/ProgressBar.tsx new file mode 100644 index 00000000..805fe491 --- /dev/null +++ b/arewerslintyet/app/ProgressBar.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { ModeToggle } from '@/components/ui/dark-mode-toggle'; +import { Linter } from './bundler'; +import Switcher from './Switcher'; + +export function ProgressBar({ linter, mostRecent, dev }) { + const testsLeft = mostRecent.total - mostRecent.passing; + return ( +
+
+ + šŸ¦€ RSLint + + {mostRecent.passing} of {mostRecent.total}{' '} + {dev ? 'development' : 'production'} lint tests passing  + + + + ({testsLeft > 0 ? <>{testsLeft} left for 100% : '100%'}) + +
+
+ + +
+
+ ); +} diff --git a/arewerslintyet/app/ProgressBarDevelopment.tsx b/arewerslintyet/app/ProgressBarDevelopment.tsx new file mode 100644 index 00000000..9c406715 --- /dev/null +++ b/arewerslintyet/app/ProgressBarDevelopment.tsx @@ -0,0 +1,15 @@ +import { getLinter } from './bundler'; +import { getDevelopmentLintRuns } from './data'; +import { ProgressBar } from './ProgressBar'; + +export default async function ProgressBarDevelopment() { + const { mostRecent } = await getDevelopmentLintRuns(); + + if (!mostRecent) { + return null; + } + + return ( + + ); +} diff --git a/arewerslintyet/app/ProgressBarProduction.tsx b/arewerslintyet/app/ProgressBarProduction.tsx new file mode 100644 index 00000000..a2ab999a --- /dev/null +++ b/arewerslintyet/app/ProgressBarProduction.tsx @@ -0,0 +1,15 @@ +import { getLinter } from './bundler'; +import { getProductionLintRuns } from './data'; +import { ProgressBar } from './ProgressBar'; + +export default async function ProgressBarProduction() { + const { mostRecent } = await getProductionLintRuns(); + + if (!mostRecent) { + return null; + } + + return ( + + ); +} diff --git a/arewerslintyet/app/Switcher.tsx b/arewerslintyet/app/Switcher.tsx new file mode 100644 index 00000000..ecef703a --- /dev/null +++ b/arewerslintyet/app/Switcher.tsx @@ -0,0 +1,40 @@ +'use client'; +import { usePathname, useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +export default function Switcher() { + const pathname = usePathname(); + const router = useRouter(); + const isProduction = pathname === '/'; + + useEffect(() => { + router.prefetch(pathname === 'development' ? '/' : '/build'); + }, [pathname, router.prefetch]); + + return ( + + ); +} diff --git a/arewerslintyet/app/TooltipContext.tsx b/arewerslintyet/app/TooltipContext.tsx new file mode 100644 index 00000000..bc36bf2b --- /dev/null +++ b/arewerslintyet/app/TooltipContext.tsx @@ -0,0 +1,115 @@ +'use client'; + +import React, { + Context, + CSSProperties, + MouseEvent, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +type TooltipStatus = 'passing' | 'failing'; + +interface TooltipProps { + flip?: boolean; + status?: TooltipStatus; + left?: number | string; + top?: number | string; + content?: ReactNode; +} + +const tooltipIcons: Record = { + passing: '\u2705', + failing: '\u274C', +}; + +const tooltipLabels: Record = { + passing: 'passing', + failing: 'failing', +}; + +const Tooltip: React.FC = props => { + let contentStyle: CSSProperties = { + right: props.flip ? -15 : 'auto', + left: props.flip ? 'auto' : -15, + }; + + let statusRow: ReactNode = null; + if (props.status) { + let icon = tooltipIcons[props.status]; + let text = tooltipLabels[props.status]; + statusRow = ( +
+ {icon} + {text} +
+ ); + } + + return ( +
+
+ {props.content} + {statusRow} +
+
+ ); +}; + +interface TooltipContextValue { + onMouseOver: ( + event: UIEvent, + content: ReactNode, + status: TooltipStatus, + ) => void; + onMouseOut: () => void; +} + +const TooltipContext: Context = + React.createContext(null); + +interface TooltipProviderProps { + children: ReactNode; +} + +export const TooltipProvider: React.FC = props => { + const [data, setData] = useState(null); + + const onMouseOver = useCallback( + (event: UIEvent, content: ReactNode, status: TooltipStatus) => { + if (!(event.target instanceof Element)) { + return; + } + let rect = event.target.getBoundingClientRect(); + let left = Math.round(rect.left + rect.width / 2 + window.scrollX); + let top = Math.round(rect.top + window.scrollY); + let flip = left > document.documentElement.clientWidth / 2; + setData({ left, top, content, status, flip }); + }, + [], + ); + + const onMouseOut = useCallback(() => { + setData(null); + }, []); + + const value = useMemo( + () => ({ onMouseOver, onMouseOut }), + [onMouseOver, onMouseOut], + ); + + return ( + + {props.children} + {data && } + + ); +}; + +export function useTooltip(): TooltipContextValue | null { + const callbacks = useContext(TooltipContext); + return callbacks; +} diff --git a/arewerslintyet/app/api/revalidate/route.ts b/arewerslintyet/app/api/revalidate/route.ts new file mode 100644 index 00000000..26af45b5 --- /dev/null +++ b/arewerslintyet/app/api/revalidate/route.ts @@ -0,0 +1,48 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { type NextRequest, NextResponse } from 'next/server'; +import { revalidateAll } from '@/app/data'; + +const API_TOKEN = process.env.AREWETURBOYET_TOKEN; + +interface RevalidationSuccess { + revalidated: true; +} + +interface RevalidationError { + error?: string; +} + +type Revalidation = RevalidationSuccess | RevalidationError; + +// Revalidates all of the data caches associated with this deployment. Intended +// to be called from GitHub actions after new data is pushed to the KV store, so +// it can be reflected immediately in the UI. +// +// Note: areweturboyet and arewerspackyet must be revalidated independently, as +// they're separate vercel projects with separate data caches. +// +// Example: https://nextjs.org/docs/app/api-reference/functions/revalidateTag#route-handler +export async function POST( + req: NextRequest, +): Promise> { + // Check for the API key in the request headers. This isn't particularly + // sensitive, but it could cost us money if somebody hit it maliciously. + const headerToken = req.headers.get('X-Auth-Token'); + if (!API_TOKEN || headerToken !== API_TOKEN) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + revalidateAll(); + return NextResponse.json({ + revalidated: true, + }); + } catch (error) { + return NextResponse.json( + { + error: (error as Error).message, + }, + { status: 500 }, + ); + } +} diff --git a/arewerslintyet/app/bundler.ts b/arewerslintyet/app/bundler.ts new file mode 100644 index 00000000..3416a0f8 --- /dev/null +++ b/arewerslintyet/app/bundler.ts @@ -0,0 +1,13 @@ +export enum Linter { + RSLint = 'rslint', +} + +export function getLinter(): Linter { + // Always return RSLint since we're only tracking one linter + return Linter.RSLint; +} + +// Legacy function name for compatibility +export function getBundler(): Linter { + return getLinter(); +} diff --git a/arewerslintyet/app/data.tsx b/arewerslintyet/app/data.tsx new file mode 100644 index 00000000..1be2f372 --- /dev/null +++ b/arewerslintyet/app/data.tsx @@ -0,0 +1,119 @@ +import 'server-only'; +import { kv } from '@vercel/kv'; +import { revalidateTag, unstable_cache } from 'next/cache'; +import { Linter, getLinter } from './bundler'; + +const kvPrefix = 'rslint-'; +const linterTag = 'rslint'; + +export function revalidateAll() { + revalidateTag(linterTag); +} + +function processGraphData(rawGraphData: string[]) { + return rawGraphData + .map(string => { + const [gitHash, dateStr, progress] = string.split(/[\t]/); + // convert to a unix epoch timestamp + const date = Date.parse(dateStr); + const [passing, total] = progress.split(/\//).map(parseFloat); + const percent = parseFloat(((passing / total) * 100).toFixed(1)); + + return { + gitHash: gitHash.slice(0, 7), + date, + total, + passing, + percent, + }; + }) + .filter(({ date, percent }) => Number.isFinite(date) && percent > 0); +} + +export const getDevelopmentLintResults = unstable_cache( + async () => { + const [failing, passing] = await Promise.all([ + kv.get(`${kvPrefix}failing-lint-tests`), + kv.get(`${kvPrefix}passing-lint-tests`), + ]); + + if (failing === null && passing === null) { + return null; + } + + return { passing, failing }; + }, + [kvPrefix, 'lint-results-new'], + { + tags: [linterTag], + revalidate: 600, + }, +); + +export const getProductionLintResults = unstable_cache( + async () => { + const [failing, passing] = await Promise.all([ + kv.get(`${kvPrefix}failing-lint-tests-production`), + kv.get(`${kvPrefix}passing-lint-tests-production`), + ]); + + if (failing === null && passing === null) { + return null; + } + + return { passing, failing }; + }, + [kvPrefix, 'lint-results-new-production'], + { + tags: [linterTag], + revalidate: 600, + }, +); + +export const getRuleExamplesResults = unstable_cache( + async () => { + const data: { [ruleName: string]: /* isPassing */ boolean } = await kv.get( + `${kvPrefix}rule-examples-data`, + ); + return data; + }, + [kvPrefix, 'rule-examples-results'], + { + tags: [linterTag], + revalidate: 600, + }, +); + +export const getDevelopmentLintRuns = unstable_cache( + async () => { + const [graphData] = await Promise.all([ + kv.lrange(`${kvPrefix}lint-runs`, 0, -1).then(processGraphData), + ]); + + const mostRecent = graphData[graphData.length - 1]; + return { graphData, mostRecent }; + }, + [kvPrefix, 'lint-runs-new'], + { + tags: [linterTag], + revalidate: 600, + }, +); + +export const getProductionLintRuns = unstable_cache( + async () => { + const [graphData] = await Promise.all([ + kv + .lrange(`${kvPrefix}lint-runs-production`, 0, -1) + .then(processGraphData), + ]); + + const mostRecent = graphData[graphData.length - 1]; + return { graphData, mostRecent }; + }, + [kvPrefix, 'lint-runs-new-production'], + { + tags: [linterTag], + revalidate: 600, + }, +); diff --git a/arewerslintyet/app/dev/page.tsx b/arewerslintyet/app/dev/page.tsx new file mode 100644 index 00000000..c4546e55 --- /dev/null +++ b/arewerslintyet/app/dev/page.tsx @@ -0,0 +1,39 @@ +import { HeapMapExamples } from 'app/HeatMapExamples'; +import { Suspense } from 'react'; +import Footer from '../Footer'; +import GraphDataDevelopment from '../GraphDataDevelopment'; +import HeatMapDevelopment from '../HeatMapDevelopment'; +import IsItReadyDevelopment from '../IsItReadyDevelopment'; +import ProgressBarDevelopment from '../ProgressBarDevelopment'; +import { TooltipProvider } from '../TooltipContext'; + +export default function DevelopmentPage() { + return ( + + {/* Development */} + + + +
+ + + + + + +

Lint Tests

+ + + + + + + +
+ + +