diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 98e4f6a9396..36221e9c909 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -1,25 +1,41 @@ name: DCR Run Lighthouse CI on: - push: - paths-ignore: - - "apps-rendering/**" + push: + branches: + - main + paths-ignore: + - "apps-rendering/**" + pull_request: + # If/when we compare results to `main`, we should also run on 'reopened' + types: [opened, synchronize] + jobs: - lhci: - name: DCR Lighthouse - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Install Node - uses: guardian/actions-setup-node@main - # Make sure we install dependencies in the root directory - - uses: bahmutov/npm-install@v1 - - run: make build - working-directory: dotcom-rendering - - name: Install and run Lighthouse CI - working-directory: dotcom-rendering - env: - LHCI_GITHUB_TOKEN: ${{ secrets.LHCI_GITHUB_TOKEN }} - run: | - npm install -g puppeteer-core@2.1.0 @lhci/cli@0.8.2 - lhci autorun + lhci: + name: DCR Lighthouse + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Install Node + uses: guardian/actions-setup-node@main + # Make sure we install dependencies in the root directory + - uses: bahmutov/npm-install@v1 + - run: make build + working-directory: dotcom-rendering + - name: Install and run Lighthouse CI + working-directory: dotcom-rendering + env: + LHCI_GITHUB_TOKEN: ${{ secrets.LHCI_GITHUB_TOKEN }} + run: | + npm install -g puppeteer-core@2.1.0 @lhci/cli@0.8.2 + lhci autorun + + - name: Setup deno + uses: denolib/setup-deno@v2 + with: + deno-version: v1.21.0 + + - name: Surface Lighthouse Results + run: deno run --no-check --allow-net=api.github.com --allow-env="GITHUB_TOKEN","GITHUB_EVENT_PATH" --allow-read scripts/deno/surface-lighthouse-results.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index fee1fdb74da..148043eb299 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ coverage # Architecture Diagram webArchitecture.svg + +# lighthouse results from local build +.lighthouseci/ \ No newline at end of file diff --git a/dotcom-rendering/lighthouserc.js b/dotcom-rendering/lighthouserc.js index ef0b3ff17ae..2fabc452261 100644 --- a/dotcom-rendering/lighthouserc.js +++ b/dotcom-rendering/lighthouserc.js @@ -10,13 +10,14 @@ module.exports = { puppeteerScript: './scripts/lighthouse/puppeteer-script.js', settings: { onlyCategories: "accessibility,best-practices,performance,seo", - disableStorageReset: true + disableStorageReset: true, } }, upload: { target: 'temporary-public-storage', }, assert: { + includePassedAssertions: true, assertions: { "first-contentful-paint": ["warn", {"maxNumericValue": 1500}], "largest-contentful-paint": ["warn", {"maxNumericValue": 3000}], diff --git a/scripts/deno/surface-lighthouse-results.ts b/scripts/deno/surface-lighthouse-results.ts new file mode 100644 index 00000000000..ebfdc609247 --- /dev/null +++ b/scripts/deno/surface-lighthouse-results.ts @@ -0,0 +1,208 @@ +import { Octokit } from "https://cdn.skypack.dev/octokit"; +import type { RestEndpointMethodTypes } from "https://cdn.skypack.dev/@octokit/plugin-rest-endpoint-methods?dts"; +import type { EventPayloadMap } from "https://cdn.skypack.dev/@octokit/webhooks-types?dts"; +import "https://raw.githubusercontent.com/GoogleChrome/lighthouse-ci/v0.8.2/types/assert.d.ts"; + +/* -- Setup -- */ + +type OctokitWithRest = { + rest: { + issues: { + [Method in keyof RestEndpointMethodTypes["issues"]]: ( + arg: RestEndpointMethodTypes["issues"][Method]["parameters"] + ) => Promise; + }; + }; +}; + +/** Github token for Authentication */ +const token = Deno.env.get("GITHUB_TOKEN"); +if (!token) throw new Error("Missing GITHUB_TOKEN"); + +/** Path for workflow event */ +const path = Deno.env.get("GITHUB_EVENT_PATH"); +if (!path) throw new Error("Missing GITHUB_EVENT_PATH"); + +/** + * https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads + */ +const payload: EventPayloadMap["push" | "pull_request"] = JSON.parse( + Deno.readTextFileSync(path) +); + +const isPullRequestEvent = ( + payload: EventPayloadMap[keyof EventPayloadMap] +): payload is EventPayloadMap["pull_request"] => + //@ts-expect-error -- We’re actually checking the type + typeof payload?.pull_request?.number === "number"; + +/** + * One of two values depending on the workflow trigger event + * + * - on a "pull_request" event: the current Issue / PR number + * on which to add or update the Lighthouse report + * + * - on a "push" event: the original issue to track the latest report: + * https://github.com/guardian/dotcom-rendering/issues/4584, which allows + * to track the state Lighthouse CI Reports on `main` over time. + */ +const issue_number = isPullRequestEvent(payload) + ? // If PullRequestEvent + payload.pull_request.number + : // If PushEvent + 4584; + +console.log(`Using issue #${issue_number}`); + +/** The Lighthouse results directory */ +const dir = "dotcom-rendering/.lighthouseci"; + +const links: Record = JSON.parse( + Deno.readTextFileSync(`${dir}/links.json`) +); + +/** https://github.com/GoogleChrome/lighthouse-ci/blob/5963dcce0e88b8d3aedaba56a93ec4b93cf073a1/packages/utils/src/assertions.js#L15-L30 */ +interface AssertionResult { + url: string; + name: + | keyof Omit + | "auditRan"; + operator: string; + expected: number; + actual: number; + values: number[]; + passed: boolean; + level?: LHCI.AssertCommand.AssertionFailureLevel; + auditId?: string; + auditProperty?: string; + auditTitle?: string; + auditDocumentationLink?: string; + message?: string; +} +const results: AssertionResult[] = JSON.parse( + Deno.readTextFileSync(`${dir}/assertion-results.json`) +); + +/* -- Definitions -- */ + +/** The string to search for when looking for a comment */ +const REPORT_TITLE = "⚡️ Lighthouse report"; +const GIHUB_PARAMS = { + owner: "guardian", + repo: "dotcom-rendering", + issue_number, +} as const; + +// @ts-expect-error -- Octokit’s own types are not as good as ours +const octokit = new Octokit({ auth: token }) as OctokitWithRest; + +/* -- Methods -- */ + +/** If large number, round to 0 decimal, if small, round to 6 decimal points */ +const formatNumber = (expected: number, actual: number): string => + expected > 100 ? Math.ceil(actual).toString() : actual.toFixed(6); + +const getStatus = ( + passed: boolean, + level: AssertionResult["level"] +): "✅" | "⚠️" | "❌" => { + if (passed) return "✅"; + + switch (level) { + case "off": + case "warn": + return "⚠️"; + case "error": + default: + return "❌"; + } +}; + +const generateAuditTable = ( + auditUrl: string, + results: AssertionResult[] +): string => { + const reportUrl = links[auditUrl]; + + const resultsTemplateString = results.map( + ({ auditTitle, auditProperty, passed, expected, actual, level }) => + `| ${auditTitle ?? auditProperty ?? "Unknown Test"} | ${getStatus( + passed, + level + )} | ${expected} | ${formatNumber(expected, actual)} |` + ); + + const [endpoint, testUrlClean] = auditUrl.split("?url="); + + const table = [ + `### [Report for ${endpoint.split("/").slice(-1)}](${reportUrl})`, + `> tested url \`${testUrlClean}\``, + "", + "| Category | Status | Expected | Actual |", + "| --- | --- | --- | --- |", + ...resultsTemplateString, + "", + ].join("\n"); + + return table; +}; + +const createLighthouseResultsMd = (): string => { + const auditCount = results.length; + const failedAuditCount = results.filter((result) => !result.passed).length; + const auditUrls = [...new Set(results.map((result) => result.url))]; + + return [ + `## ${REPORT_TITLE} for the changes in this PR`, + `Lighthouse tested ${auditUrls.length} URLs `, + failedAuditCount > 0 + ? `⚠️ Budget exceeded for ${failedAuditCount} of ${auditCount} audits.` + : "All audits passed", + ...auditUrls.map((url) => + generateAuditTable( + url, + results.filter((result) => result.url === url) + ) + ), + ].join("\n\n"); +}; + +const getCommentID = async (): Promise => { + const { data: comments } = await octokit.rest.issues.listComments({ + ...GIHUB_PARAMS, + }); + + const comment = comments.find((comment) => + comment.body?.includes(REPORT_TITLE) + ); + + return comment?.id ?? null; +}; + +try { + const body = createLighthouseResultsMd(); + const comment_id = await getCommentID(); + + const { data } = comment_id + ? await octokit.rest.issues.updateComment({ + ...GIHUB_PARAMS, + comment_id, + body, + }) + : await octokit.rest.issues.createComment({ + ...GIHUB_PARAMS, + body, + }); + + console.log( + `Successfully ${ + comment_id ? "updated" : "created" + } Lighthouse report comment` + ); + console.log("See:", data.html_url); +} catch (error) { + if (error instanceof Error) throw error; + + console.error("there was an error:", error.message); + Deno.exit(1); +}