diff --git a/.github/workflows/buildAndDeploy.yml b/.github/workflows/buildAndDeploy.yml index 0e00e9b..03cea12 100644 --- a/.github/workflows/buildAndDeploy.yml +++ b/.github/workflows/buildAndDeploy.yml @@ -13,14 +13,11 @@ jobs: group: build-and-deploy cancel-in-progress: true runs-on: ubuntu-24.04 - env: - # https://github.blog/changelog/2024-03-07-github-actions-all-actions-will-run-on-node20-instead-of-node16-by-default/ - FORCE_JAVASCRIPT_ACTIONS_TO_NODE20: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 @@ -49,7 +46,7 @@ jobs: run: PLAYWRIGHT_USE_BUILD=1 npm run test:e2e - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: ${{ github.ref == 'refs/heads/master' }} with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/checkPullRequests.yml b/.github/workflows/checkPullRequests.yml index a6ff738..d1b55d1 100644 --- a/.github/workflows/checkPullRequests.yml +++ b/.github/workflows/checkPullRequests.yml @@ -11,14 +11,11 @@ jobs: group: ${{ github.head_ref }} cancel-in-progress: true runs-on: ubuntu-24.04 - env: - # https://github.blog/changelog/2024-03-07-github-actions-all-actions-will-run-on-node20-instead-of-node16-by-default/ - FORCE_JAVASCRIPT_ACTIONS_TO_NODE20: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 diff --git a/.github/workflows/makeArtifactWithTestScreenshots.yml b/.github/workflows/makeArtifactWithTestScreenshots.yml index 8f4f339..1b14d97 100644 --- a/.github/workflows/makeArtifactWithTestScreenshots.yml +++ b/.github/workflows/makeArtifactWithTestScreenshots.yml @@ -6,14 +6,11 @@ jobs: make_artifact_with_test_screenshots: name: Make artifact with Test Screenshots runs-on: ubuntu-24.04 - env: - # https://github.blog/changelog/2024-03-07-github-actions-all-actions-will-run-on-node20-instead-of-node16-by-default/ - FORCE_JAVASCRIPT_ACTIONS_TO_NODE20: true steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20 diff --git a/e2e/vue.spec.ts b/e2e/vue.spec.ts index ab97238..a85e9d3 100644 --- a/e2e/vue.spec.ts +++ b/e2e/vue.spec.ts @@ -1,21 +1,53 @@ import { expect, test } from "@playwright/test"; -// See here how to get started: -// https://playwright.dev/docs/intro +// Documentation: https://playwright.dev/docs/intro + +let errorMessagesCount = 0; + +const ignoreErrors = [ + "ResizeObserver loop completed with undelivered notifications.", +]; + +// Register a global error listener +test.beforeEach(async ({ page }) => { + errorMessagesCount = 0; + + page.on("pageerror", (error) => { + if (ignoreErrors.includes(error.message)) { + return; + } + console.log(">> Console error: ", error); + ++errorMessagesCount; + }); +}); + +test.afterEach(() => { + expect(errorMessagesCount).toBe(0); +}); + test("visits the app root url, sitemap.txt and robots.txt", async ({ page, + browserName, }) => { await page.goto("/"); + await page.waitForTimeout(2000); await expect(page.locator("h1")).toHaveText("Get Crypto Address"); + // Next tests are chromium only + if (browserName !== "chromium") { + return; + } + if (process.env.PLAYWRIGHT_USE_BUILD) { await page.goto("/sitemap.txt"); + await page.waitForTimeout(500); expect(await page.locator("pre").innerText()).toMatchSnapshot( "sitemap.txt", ); } await page.goto("/robots.txt"); + await page.waitForTimeout(500); expect(await page.locator("pre").innerText()).toMatchSnapshot("robots.txt"); }); @@ -52,6 +84,7 @@ test("General flow", async ({ page, context, browserName }) => { // Generate new addresses await page.getByRole("button", { name: "Generate new addresses" }).click(); + await page.waitForTimeout(500); // Check the count of generated addresses const $addresses = page.locator('[data-test-el="key-address-item"]'); @@ -88,6 +121,7 @@ test("General flow", async ({ page, context, browserName }) => { await $openModalButton.click(); const $modal = page.getByRole("dialog"); await $modal.waitFor({ state: "visible", timeout: 1000 }); + await page.waitForTimeout(100); const $modalSecret = $modal.locator( '[data-test-id="dialog-qr-code-secret"] .n-thing-main__description', ); @@ -99,6 +133,7 @@ test("General flow", async ({ page, context, browserName }) => { const $modalMask = page.locator(".n-modal-mask"); await page.mouse.click(1, 1); await $modalMask.waitFor({ state: "detached", timeout: 1000 }); + await page.waitForTimeout(100); } /// Paper wallet page diff --git a/node/csp/addInlineStylesHashesToHtml.mjs b/node/csp/addInlineStylesHashesToHtml.mjs new file mode 100644 index 0000000..a67d6cf --- /dev/null +++ b/node/csp/addInlineStylesHashesToHtml.mjs @@ -0,0 +1,18 @@ +/** + * Add hashes of inline styles to the CSP policy in the HTML. + * + * @description Naive-ui uses inline styles to style components. + * + * [tag-nonce] + * @param {string} html + * @param {string[]} listOfHashes + * @returns {*} + */ +export function addInlineStylesHashesToHtml(html, listOfHashes) { + const hashes = listOfHashes.map((hash) => `'${hash}'`).join(" "); + + return html.replace( + "style-src 'self'", + `style-src 'self' 'unsafe-hashes' ${hashes}`, + ); +} diff --git a/node/csp/getInlineStylesHashes.mjs b/node/csp/getInlineStylesHashes.mjs new file mode 100644 index 0000000..0f0ca57 --- /dev/null +++ b/node/csp/getInlineStylesHashes.mjs @@ -0,0 +1,46 @@ +import crypto from "crypto"; + +/** + * Calculates SHA-256 hash of the given style content and returns it in base64 format. + * + * ``` + * # Input + * max-width:250px;text-align:left;margin:0 auto;width:100%; + * # output + * sha256-O5IIiIzIB9wS0DmNOhwTAp7C6vPXN8QJ3R0ZS+HTUNM= + * ``` + * + * @param {string} styleContent + * @returns {string} + */ +function calculateStyleHash(styleContent) { + const hash = crypto + .createHash("sha256") + .update(styleContent, "utf8") + .digest("base64"); + return `sha256-${hash}`; +} + +/** + * Extracts inline styles from the given HTML content. + * + * @param {string} appHtml - The HTML content to extract inline styles from. + * @returns {string[]} An array of inline style strings. + */ +function getInlineStyles(appHtml) { + return ( + appHtml + .match(/ style=".*?"/g) + ?.map((line) => line.replace(/^ style="/, "").replace(/"$/, "")) || [] + ); +} + +/** + * Generates an array of unique SHA-256 hashes for all inline styles found in the given HTML content. + * + * @param {string} appHtml - The HTML content to extract and hash inline styles from. + * @returns {string[]} An array of unique SHA-256 hashes in base64 format. + */ +export function getInlineStylesHashes(appHtml) { + return [...new Set(getInlineStyles(appHtml).map(calculateStyleHash))]; +} diff --git a/prerender.mjs b/prerender.mjs index 1109477..dece925 100644 --- a/prerender.mjs +++ b/prerender.mjs @@ -1,6 +1,8 @@ import fs from "node:fs"; import path from "node:path"; import { createServer } from "vite"; +import { addInlineStylesHashesToHtml } from "./node/csp/addInlineStylesHashesToHtml.mjs"; +import { getInlineStylesHashes } from "./node/csp/getInlineStylesHashes.mjs"; import generateSitemap from "./node/sitemap/generateSitemap.mjs"; // todo refactor file, separate into functions @@ -21,7 +23,12 @@ generateSitemap(routerPaths, "https://getcryptoaddress.github.io", "dist"); for (const routerPath of routerPaths) { const { appHtml, ctx } = await render(routerPath); - const pageHtml = template + let pageHtml = template; + + const styleHashes = getInlineStylesHashes(appHtml); + pageHtml = addInlineStylesHashesToHtml(pageHtml, styleHashes); + + pageHtml = pageHtml .replace("", ctx?.teleports?.head || "") .replace("", appHtml) .replace(//g, "") @@ -32,7 +39,6 @@ for (const routerPath of routerPaths) { recursive: true, }); fs.writeFileSync(path.join(pageFolder, "index.html"), pageHtml); - console.log("Generated:", path.join(pageFolder, "index.html")); await new Promise((resolve) => setTimeout(resolve, 300)); } await vite.close();