diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3d62205 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build and validate content + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Validate blog frontmatter + run: yarn validate:content + + - name: Build site + run: yarn build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: docusaurus-build + path: build + retention-days: 1 + + e2e: + name: E2E and visual tests + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: docusaurus-build + path: build + + - name: Install Playwright browsers + run: yarn playwright install --with-deps chromium + + - name: Run Playwright tests + run: yarn test:e2e + + - name: Upload Playwright test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/update-snapshots.yml b/.github/workflows/update-snapshots.yml new file mode 100644 index 0000000..203b04e --- /dev/null +++ b/.github/workflows/update-snapshots.yml @@ -0,0 +1,57 @@ +name: Update Playwright snapshots + +on: + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-snapshots: + name: Regenerate visual baselines + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + + - name: Enable Corepack + run: corepack enable + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + .yarn/install-state.gz + key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install dependencies + run: yarn install --immutable + + - name: Build site + run: yarn build + + - name: Install Playwright browsers + run: yarn playwright install --with-deps chromium + + - name: Update snapshots + run: yarn test:e2e:update + + - name: Commit and push snapshots + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add e2e/site.spec.ts-snapshots/ + if git diff --staged --quiet; then + echo "No snapshot changes to commit." + exit 0 + fi + git commit -m "chore(e2e): update Playwright visual snapshots" + git push diff --git a/.gitignore b/.gitignore index b2d6de3..40f6a01 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 2681b95..669134a 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/e2e/site.spec.ts b/e2e/site.spec.ts new file mode 100644 index 0000000..2112cfa --- /dev/null +++ b/e2e/site.spec.ts @@ -0,0 +1,59 @@ +import { expect, test, type Page } from '@playwright/test'; + +async function prepareVisualSnapshot(page: Page) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + font-family: Arial, Helvetica, sans-serif !important; + } + `, + }); + await page.evaluate(() => document.fonts.ready); +} + +test.describe('site smoke tests', () => { + test.beforeEach(({ }, testInfo) => { + test.skip( + testInfo.project.name !== 'desktop', + 'Smoke tests run on desktop only', + ); + }); + + test('homepage lists blog posts', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/yakov\.dev/i); + await expect( + page.getByRole('navigation').getByRole('link', { name: /GitHub/i }), + ).toBeVisible(); + await expect(page.getByRole('article').first()).toBeVisible(); + }); + + test('blog post page renders', async ({ page }) => { + await page.goto('/recursion-in-react-simplified'); + await expect(page.getByRole('heading', { level: 1 })).toHaveText( + 'Recursion in React simplified', + ); + }); +}); + +test.describe('visual regression', () => { + test('homepage screenshot', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('article').first()).toBeVisible(); + await prepareVisualSnapshot(page); + await expect(page).toHaveScreenshot('homepage.png', { + animations: 'disabled', + fullPage: false, + }); + }); + + test('blog post screenshot', async ({ page }) => { + await page.goto('/recursion-in-react-simplified'); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + await prepareVisualSnapshot(page); + await expect(page).toHaveScreenshot('blog-post.png', { + animations: 'disabled', + fullPage: false, + }); + }); +}); diff --git a/e2e/site.spec.ts-snapshots/blog-post-desktop.png b/e2e/site.spec.ts-snapshots/blog-post-desktop.png new file mode 100644 index 0000000..3c983fc Binary files /dev/null and b/e2e/site.spec.ts-snapshots/blog-post-desktop.png differ diff --git a/e2e/site.spec.ts-snapshots/blog-post-mobile.png b/e2e/site.spec.ts-snapshots/blog-post-mobile.png new file mode 100644 index 0000000..165ab9b Binary files /dev/null and b/e2e/site.spec.ts-snapshots/blog-post-mobile.png differ diff --git a/e2e/site.spec.ts-snapshots/homepage-desktop.png b/e2e/site.spec.ts-snapshots/homepage-desktop.png new file mode 100644 index 0000000..2269c3b Binary files /dev/null and b/e2e/site.spec.ts-snapshots/homepage-desktop.png differ diff --git a/e2e/site.spec.ts-snapshots/homepage-mobile.png b/e2e/site.spec.ts-snapshots/homepage-mobile.png new file mode 100644 index 0000000..11237f4 Binary files /dev/null and b/e2e/site.spec.ts-snapshots/homepage-mobile.png differ diff --git a/package.json b/package.json index 993b31b..2dc4dc5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids" + "write-heading-ids": "docusaurus write-heading-ids", + "validate:content": "node scripts/validate-content.mjs", + "test:e2e": "playwright test", + "test:e2e:update": "playwright test --update-snapshots" }, "dependencies": { "@docusaurus/core": "3.10.1", @@ -28,7 +31,9 @@ "devDependencies": { "@docusaurus/module-type-aliases": "3.10.1", "@docusaurus/tsconfig": "3.10.1", - "typescript": "^5.1.3" + "@playwright/test": "^1.52.0", + "typescript": "^5.1.3", + "yaml": "^2.8.0" }, "browserslist": { "production": [ diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..f5010cd --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,42 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = 3000; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e', + snapshotPathTemplate: + '{testDir}/{testFilePath}-snapshots/{arg}-{projectName}{ext}', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL, + trace: 'on-first-retry', + // Keep visual snapshots stable in CI (gtag is configured in docusaurus.config.js). + serviceWorkers: 'block', + }, + expect: { + toHaveScreenshot: { + maxDiffPixelRatio: 0.05, + }, + }, + projects: [ + { + name: 'desktop', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'mobile', + use: { ...devices['Pixel 7'] }, + }, + ], + webServer: { + command: `yarn docusaurus serve --port ${port} --host 127.0.0.1 --no-open`, + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/scripts/validate-content.mjs b/scripts/validate-content.mjs new file mode 100644 index 0000000..6ae8285 --- /dev/null +++ b/scripts/validate-content.mjs @@ -0,0 +1,145 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseYaml } from 'yaml'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.join(__dirname, '..'); +const blogDir = path.join(rootDir, 'blog'); + +const REQUIRED_FIELDS = ['slug', 'title', 'description', 'authors', 'tags']; + +function loadAuthors() { + const authorsPath = path.join(blogDir, 'authors.yml'); + const authors = parseYaml(fs.readFileSync(authorsPath, 'utf8')); + return new Set(Object.keys(authors)); +} + +function listBlogPosts(dir) { + const posts = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'authors.yml') { + continue; + } + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + posts.push(...listBlogPosts(fullPath)); + continue; + } + if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) { + posts.push(fullPath); + } + } + return posts; +} + +function parseFrontmatter(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + if (!content.startsWith('---')) { + throw new Error(`Missing frontmatter in ${filePath}`); + } + + const closingIndex = content.indexOf('\n---', 4); + if (closingIndex === -1) { + throw new Error(`Unclosed frontmatter in ${filePath}`); + } + + return parseYaml(content.slice(4, closingIndex)); +} + +function normalizeAuthors(authors) { + if (typeof authors === 'string') { + return [authors]; + } + if (Array.isArray(authors)) { + return authors.map((author) => String(author)); + } + return []; +} + +function validatePost(filePath, frontmatter, knownAuthors) { + const errors = []; + const relativePath = path.relative(rootDir, filePath); + + for (const field of REQUIRED_FIELDS) { + const value = frontmatter[field]; + if ( + value === undefined || + value === null || + value === '' || + (Array.isArray(value) && value.length === 0) + ) { + errors.push(`${relativePath}: missing required frontmatter field "${field}"`); + } + } + + if (frontmatter.slug && typeof frontmatter.slug !== 'string') { + errors.push(`${relativePath}: "slug" must be a string`); + } + + if (frontmatter.title && typeof frontmatter.title !== 'string') { + errors.push(`${relativePath}: "title" must be a string`); + } + + if (frontmatter.description && typeof frontmatter.description !== 'string') { + errors.push(`${relativePath}: "description" must be a string`); + } + + const authors = normalizeAuthors(frontmatter.authors); + if (authors.length === 0 && frontmatter.authors !== undefined) { + errors.push(`${relativePath}: "authors" must be a string or non-empty array`); + } + + for (const author of authors) { + if (!knownAuthors.has(author)) { + errors.push( + `${relativePath}: unknown author "${author}" (expected one of: ${[...knownAuthors].join(', ')})`, + ); + } + } + + if (frontmatter.tags && !Array.isArray(frontmatter.tags)) { + errors.push(`${relativePath}: "tags" must be an array`); + } + + return errors; +} + +function main() { + const knownAuthors = loadAuthors(); + const posts = listBlogPosts(blogDir); + const errors = []; + + if (posts.length === 0) { + console.error('No blog posts found to validate.'); + process.exit(1); + } + + for (const postPath of posts) { + let frontmatter; + try { + frontmatter = parseFrontmatter(postPath); + } catch (error) { + errors.push(`${path.relative(rootDir, postPath)}: ${error.message}`); + continue; + } + + if (frontmatter?.draft === true) { + continue; + } + + errors.push(...validatePost(postPath, frontmatter, knownAuthors)); + } + + if (errors.length > 0) { + console.error('Content validation failed:\n'); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + + console.log(`Validated ${posts.length} blog post(s).`); +} + +main(); diff --git a/yarn.lock b/yarn.lock index 835e2ac..b1c5565 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3548,6 +3548,17 @@ __metadata: languageName: node linkType: hard +"@playwright/test@npm:^1.52.0": + version: 1.60.0 + resolution: "@playwright/test@npm:1.60.0" + dependencies: + playwright: "npm:1.60.0" + bin: + playwright: cli.js + checksum: 10c0/86b06e6437933e741c7cd43f362024e857e7bc28a55fcbb0553ef55e01a2a403c64f4786868de8af86a6e303fe99e98a18a42ba19489f43ae122e457f9e2d189 + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -6998,7 +7009,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:~2.3.2": +"fsevents@npm:2.3.2, fsevents@npm:~2.3.2": version: 2.3.2 resolution: "fsevents@npm:2.3.2" dependencies: @@ -7008,7 +7019,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": +"fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" dependencies: @@ -9632,6 +9643,7 @@ __metadata: "@docusaurus/preset-classic": "npm:3.10.1" "@docusaurus/tsconfig": "npm:3.10.1" "@mdx-js/react": "npm:^3.0.0" + "@playwright/test": "npm:^1.52.0" "@vercel/analytics": "npm:^1.0.1" clsx: "npm:^2.0.0" prism-react-renderer: "npm:^2.0.0" @@ -9639,6 +9651,7 @@ __metadata: react: "npm:^18.0.0" react-dom: "npm:^18.0.0" typescript: "npm:^5.1.3" + yaml: "npm:^2.8.0" languageName: unknown linkType: soft @@ -10215,6 +10228,30 @@ __metadata: languageName: node linkType: hard +"playwright-core@npm:1.60.0": + version: 1.60.0 + resolution: "playwright-core@npm:1.60.0" + bin: + playwright-core: cli.js + checksum: 10c0/99ccd43923b6e9355e0723b7fe221e6326efd4687f8dafff951313662aea11db51f542a9c2122c704c445fb9baae1c9ec9fa6f895126bbddd9fe92313f6942c9 + languageName: node + linkType: hard + +"playwright@npm:1.60.0": + version: 1.60.0 + resolution: "playwright@npm:1.60.0" + dependencies: + fsevents: "npm:2.3.2" + playwright-core: "npm:1.60.0" + dependenciesMeta: + fsevents: + optional: true + bin: + playwright: cli.js + checksum: 10c0/714ad76d85b4865d7e43c0012f9039800c1485373388973ed39d79339cee5ad467052d1e2f1eaeca107a1cb6e65342186a8578a4c3504853d84c3a691250d5db + languageName: node + linkType: hard + "postcss-attribute-case-insensitive@npm:^7.0.1": version: 7.0.1 resolution: "postcss-attribute-case-insensitive@npm:7.0.1" @@ -13579,6 +13616,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.0": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10c0/f340718df45e97a9551b9bf9dac61c80050bc464513b710debfb5067c380c8472e3b67809cffacb4ab5ffb5e66ef9310816c88b05f371cec60abfedd8c88e0a2 + languageName: node + linkType: hard + "yocto-queue@npm:^1.0.0": version: 1.2.1 resolution: "yocto-queue@npm:1.2.1"