From 5b506b12608061bd5876ff3acd1122baba61ab08 Mon Sep 17 00:00:00 2001 From: Tony Sullivan Date: Wed, 11 May 2022 15:21:52 -0600 Subject: [PATCH] adding Tailwind E2E tests with Playwright --- .../e2e/fixtures/tailwindcss/astro.config.mjs | 12 ++ .../e2e/fixtures/tailwindcss/package.json | 9 + .../fixtures/tailwindcss/postcss.config.js | 9 + .../tailwindcss/src/components/Button.astro | 10 + .../tailwindcss/src/components/Complex.astro | 1 + .../tailwindcss/src/pages/index.astro | 18 ++ .../tailwindcss/src/pages/markdown-page.md | 11 + .../fixtures/tailwindcss/tailwind.config.js | 14 ++ packages/astro/e2e/tailwindcss.test.js | 98 +++++++++ packages/astro/e2e/test-utils.js | 202 ++++++++++++++++++ packages/astro/package.json | 6 +- 11 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 packages/astro/e2e/fixtures/tailwindcss/astro.config.mjs create mode 100644 packages/astro/e2e/fixtures/tailwindcss/package.json create mode 100644 packages/astro/e2e/fixtures/tailwindcss/postcss.config.js create mode 100644 packages/astro/e2e/fixtures/tailwindcss/src/components/Button.astro create mode 100644 packages/astro/e2e/fixtures/tailwindcss/src/components/Complex.astro create mode 100644 packages/astro/e2e/fixtures/tailwindcss/src/pages/index.astro create mode 100644 packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md create mode 100644 packages/astro/e2e/fixtures/tailwindcss/tailwind.config.js create mode 100644 packages/astro/e2e/tailwindcss.test.js create mode 100644 packages/astro/e2e/test-utils.js diff --git a/packages/astro/e2e/fixtures/tailwindcss/astro.config.mjs b/packages/astro/e2e/fixtures/tailwindcss/astro.config.mjs new file mode 100644 index 0000000000..473be9666e --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/astro.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'astro/config'; +import tailwind from '@astrojs/tailwind'; + +// https://astro.build/config +export default defineConfig({ + integrations: [tailwind()], + vite: { + build: { + assetsInlineLimit: 0, + }, + }, +}); diff --git a/packages/astro/e2e/fixtures/tailwindcss/package.json b/packages/astro/e2e/fixtures/tailwindcss/package.json new file mode 100644 index 0000000000..4bcc568727 --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/e2e-tailwindcss", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/tailwind": "workspace:*" + } +} diff --git a/packages/astro/e2e/fixtures/tailwindcss/postcss.config.js b/packages/astro/e2e/fixtures/tailwindcss/postcss.config.js new file mode 100644 index 0000000000..7df5ecb396 --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/postcss.config.js @@ -0,0 +1,9 @@ +const path = require('path'); +module.exports = { + plugins: { + tailwindcss: { + config: path.join(__dirname, 'tailwind.config.js'), // update this if your path differs! + }, + autoprefixer: {} + }, +}; diff --git a/packages/astro/e2e/fixtures/tailwindcss/src/components/Button.astro b/packages/astro/e2e/fixtures/tailwindcss/src/components/Button.astro new file mode 100644 index 0000000000..6e08721739 --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/src/components/Button.astro @@ -0,0 +1,10 @@ +--- +let { type = 'button' } = Astro.props; +--- + + diff --git a/packages/astro/e2e/fixtures/tailwindcss/src/components/Complex.astro b/packages/astro/e2e/fixtures/tailwindcss/src/components/Complex.astro new file mode 100644 index 0000000000..bd30373c8e --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/src/components/Complex.astro @@ -0,0 +1 @@ +
diff --git a/packages/astro/e2e/fixtures/tailwindcss/src/pages/index.astro b/packages/astro/e2e/fixtures/tailwindcss/src/pages/index.astro new file mode 100644 index 0000000000..d901b4233a --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/src/pages/index.astro @@ -0,0 +1,18 @@ +--- +// Component Imports +import Button from '../components/Button.astro'; +import Complex from '../components/Complex.astro'; +--- + + + + + + Astro + TailwindCSS + + + + + + + diff --git a/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md b/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md new file mode 100644 index 0000000000..e4c6b6bc9d --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/src/pages/markdown-page.md @@ -0,0 +1,11 @@ +--- +title: "Markdown + Tailwind" +setup: | + import Button from '../components/Button.astro'; + import Complex from '../components/Complex.astro'; +--- + +
+ + +
\ No newline at end of file diff --git a/packages/astro/e2e/fixtures/tailwindcss/tailwind.config.js b/packages/astro/e2e/fixtures/tailwindcss/tailwind.config.js new file mode 100644 index 0000000000..7aeb483c1e --- /dev/null +++ b/packages/astro/e2e/fixtures/tailwindcss/tailwind.config.js @@ -0,0 +1,14 @@ +const path = require('path'); + +module.exports = { + content: [path.join(__dirname, 'src/**/*.{astro,html,js,jsx,md,svelte,ts,tsx,vue}')], + theme: { + extend: { + colors: { + dawn: '#f3e9fa', + dusk: '#514375', + midnight: '#31274a', + } + } + } +}; diff --git a/packages/astro/e2e/tailwindcss.test.js b/packages/astro/e2e/tailwindcss.test.js new file mode 100644 index 0000000000..bb2c7abba6 --- /dev/null +++ b/packages/astro/e2e/tailwindcss.test.js @@ -0,0 +1,98 @@ +import { test as base, expect } from '@playwright/test'; +import { loadFixture } from './test-utils.js'; + +const test = base.extend({ + astro: async ({ }, use) => { + const fixture = await loadFixture({ root: './fixtures/tailwindcss/' }); + await use(fixture); + }, +}); + +test.describe('dev', () => { + let devServer; + + test.beforeAll(async ({ astro }) => { + devServer = await astro.startDevServer(); + }); + + test.afterAll(async ({ astro }) => { + await devServer.stop(); + }); + + test('Tailwind CSS', async ({ page }) => { + await page.goto(`localhost:${devServer.address.port}/`); + + await test.step('body', async () => { + const body = page.locator('body'); + + await expect(body, 'should have classes').toHaveClass('bg-dawn text-midnight'); + await expect(body, 'should have background color').toHaveCSS( + 'background-color', + 'rgb(243, 233, 250)' + ); + await expect(body, 'should have color').toHaveCSS('color', 'rgb(49, 39, 74)'); + }); + + await test.step('button', async () => { + const button = page.locator('button'); + + await expect(button, 'should have bg-purple-600').toHaveClass(/bg-purple-600/); + await expect(button, 'should have background color').toHaveCSS( + 'background-color', + 'rgb(147, 51, 234)' + ); + + await expect(button, 'should have lg:py-3').toHaveClass(/lg:py-3/); + await expect(button, 'should have padding bottom').toHaveCSS('padding-bottom', '12px'); + await expect(button, 'should have padding top').toHaveCSS('padding-top', '12px'); + + await expect(button, 'should have font-[900]').toHaveClass(/font-\[900\]/); + await expect(button, 'should have font weight').toHaveCSS('font-weight', '900'); + }); + }); +}); + +test.describe('build', () => { + let previewServer; + + test.beforeAll(async ({ astro }) => { + await astro.build(); + previewServer = await astro.preview(); + }); + + test.afterAll(async ({ astro }) => { + await previewServer.stop(); + }) + + test('Tailwind CSS', async ({ page }) => { + await page.goto(`localhost:3000/`); + + await test.step('body', async () => { + const body = page.locator('body'); + + await expect(body, 'should have classes').toHaveClass('bg-dawn text-midnight'); + await expect(body, 'should have background color').toHaveCSS( + 'background-color', + 'rgb(243, 233, 250)' + ); + await expect(body, 'should have color').toHaveCSS('color', 'rgb(49, 39, 74)'); + }); + + await test.step('button', async () => { + const button = page.locator('button'); + + await expect(button, 'should have bg-purple-600').toHaveClass(/bg-purple-600/); + await expect(button, 'should have background color').toHaveCSS( + 'background-color', + 'rgb(147, 51, 234)' + ); + + await expect(button, 'should have lg:py-3').toHaveClass(/lg:py-3/); + await expect(button, 'should have padding bottom').toHaveCSS('padding-bottom', '12px'); + await expect(button, 'should have padding top').toHaveCSS('padding-top', '12px'); + + await expect(button, 'should have font-[900]').toHaveClass(/font-\[900\]/); + await expect(button, 'should have font weight').toHaveCSS('font-weight', '900'); + }); + }); +}); diff --git a/packages/astro/e2e/test-utils.js b/packages/astro/e2e/test-utils.js new file mode 100644 index 0000000000..460b3185f0 --- /dev/null +++ b/packages/astro/e2e/test-utils.js @@ -0,0 +1,202 @@ +import { execa } from 'execa'; +import { polyfill } from '@astrojs/webapi'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { resolveConfig, loadConfig } from '../dist/core/config.js'; +import dev from '../dist/core/dev/index.js'; +import build from '../dist/core/build/index.js'; +import preview from '../dist/core/preview/index.js'; +import { nodeLogDestination } from '../dist/core/logger/node.js'; +import os from 'os'; +import stripAnsi from 'strip-ansi'; + +// polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16 +polyfill(globalThis, { + exclude: 'window document', +}); + +/** + * @typedef {import('node-fetch').Response} Response + * @typedef {import('../src/core/dev/index').DevServer} DevServer + * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig + * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer + * @typedef {import('../src/core/app/index').App} App + * + * + * @typedef {Object} Fixture + * @property {typeof build} build + * @property {(url: string, opts: any) => Promise} fetch + * @property {(path: string) => Promise} readFile + * @property {(path: string) => Promise} readdir + * @property {() => Promise} startDevServer + * @property {() => Promise} preview + * @property {() => Promise} clean + * @property {() => Promise} loadTestAdapterApp + */ + +/** + * Load Astro fixture + * @param {AstroConfig} inlineConfig Astro config partial (note: must specify `root`) + * @returns {Promise} The fixture. Has the following properties: + * .config - Returns the final config. Will be automatically passed to the methods below: + * + * Build + * .build() - Async. Builds into current folder (will erase previous build) + * .readFile(path) - Async. Read a file from the build. + * + * Dev + * .startDevServer() - Async. Starts a dev server at an available port. Be sure to call devServer.stop() before test exit. + * .fetch(url) - Async. Returns a URL from the prevew server (must have called .preview() before) + * + * Preview + * .preview() - Async. Starts a preview server. Note this can’t be running in same fixture as .dev() as they share ports. Also, you must call `server.close()` before test exit + * + * Clean-up + * .clean() - Async. Removes the project’s dist folder. + */ +export async function loadFixture(inlineConfig) { + if (!inlineConfig || !inlineConfig.root) + throw new Error("Must provide { root: './fixtures/...' }"); + + // load config + let cwd = inlineConfig.root; + delete inlineConfig.root; + if (typeof cwd === 'string') { + try { + cwd = new URL(cwd.replace(/\/?$/, '/')); + } catch (err1) { + cwd = new URL(cwd.replace(/\/?$/, '/'), import.meta.url); + } + } + // Load the config. + let config = await loadConfig({ cwd: fileURLToPath(cwd) }); + config = merge(config, { ...inlineConfig, root: cwd }); + + // Note: the inline config doesn't run through config validation where these normalizations usually occur + if (typeof inlineConfig.site === 'string') { + config.site = new URL(inlineConfig.site); + } + if (inlineConfig.base && !inlineConfig.base.endsWith('/')) { + config.base = inlineConfig.base + '/'; + } + + /** @type {import('../src/core/logger/core').LogOptions} */ + const logging = { + dest: nodeLogDestination, + level: 'error', + }; + + /** @type {import('@astrojs/telemetry').AstroTelemetry} */ + const telemetry = { + record() { + return Promise.resolve(); + }, + }; + + return { + build: (opts = {}) => build(config, { mode: 'development', logging, telemetry, ...opts }), + startDevServer: async (opts = {}) => { + const devResult = await dev(config, { logging, telemetry, ...opts }); + config.server.port = devResult.address.port; // update port + return devResult; + }, + config, + fetch: (url, init) => + fetch(`http://${'127.0.0.1'}:${config.server.port}${url.replace(/^\/?/, '/')}`, init), + preview: async (opts = {}) => { + const previewServer = await preview(config, { logging, telemetry, ...opts }); + return previewServer; + }, + readFile: (filePath) => + fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.outDir), 'utf8'), + readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.outDir)), + clean: () => fs.promises.rm(config.outDir, { maxRetries: 10, recursive: true, force: true }), + loadTestAdapterApp: async () => { + const url = new URL('./server/entry.mjs', config.outDir); + const { createApp } = await import(url); + return createApp(); + }, + }; +} + +/** + * Basic object merge utility. Returns new copy of merged Object. + * @param {Object} a + * @param {Object} b + * @returns {Object} + */ +function merge(a, b) { + const allKeys = new Set([...Object.keys(a), ...Object.keys(b)]); + const c = {}; + for (const k of allKeys) { + const needsObjectMerge = + typeof a[k] === 'object' && + typeof b[k] === 'object' && + (Object.keys(a[k]).length || Object.keys(b[k]).length) && + !Array.isArray(a[k]) && + !Array.isArray(b[k]); + if (needsObjectMerge) { + c[k] = merge(a[k] || {}, b[k] || {}); + continue; + } + c[k] = a[k]; + if (b[k] !== undefined) c[k] = b[k]; + } + return c; +} + +const cliPath = fileURLToPath(new URL('../astro.js', import.meta.url)); + +/** Returns a process running the Astro CLI. */ +export function cli(/** @type {string[]} */ ...args) { + const spawned = execa('node', [cliPath, ...args]); + + spawned.stdout.setEncoding('utf8'); + + return spawned; +} + +export async function parseCliDevStart(proc) { + let stdout = ''; + let stderr = ''; + + for await (const chunk of proc.stdout) { + stdout += chunk; + if (chunk.includes('Local')) break; + } + if (!stdout) { + for await (const chunk of proc.stderr) { + stderr += chunk; + break; + } + } + + proc.kill(); + stdout = stripAnsi(stdout); + stderr = stripAnsi(stderr); + + if (stderr) { + throw new Error(stderr); + } + + const messages = stdout + .split('\n') + .filter((ln) => !!ln.trim()) + .map((ln) => ln.replace(/[🚀┃]/g, '').replace(/\s+/g, ' ').trim()); + + return { messages }; +} + +export async function cliServerLogSetup(flags = [], cmd = 'dev') { + const proc = cli(cmd, ...flags); + + const { messages } = await parseCliDevStart(proc); + + const local = messages.find((msg) => msg.includes('Local'))?.replace(/Local\s*/g, ''); + const network = messages.find((msg) => msg.includes('Network'))?.replace(/Network\s*/g, ''); + + return { local, network }; +} + +export const isWindows = os.platform() === 'win32'; diff --git a/packages/astro/package.json b/packages/astro/package.json index 86939565e7..c4d266a5d2 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -72,7 +72,8 @@ "postbuild": "astro-scripts copy \"src/**/*.astro\"", "benchmark": "node test/benchmark/dev.bench.js && node test/benchmark/build.bench.js", "test": "mocha --exit --timeout 20000 --ignore **/lit-element.test.js && mocha --timeout 20000 **/lit-element.test.js", - "test:match": "mocha --timeout 20000 -g" + "test:match": "mocha --timeout 20000 -g", + "test:e2e": "playwright test e2e" }, "dependencies": { "@astrojs/compiler": "^0.14.2", @@ -134,7 +135,8 @@ "zod": "^3.16.0" }, "devDependencies": { - "@babel/types": "^7.17.10", + "@babel/types": "^7.17.0", + "@playwright/test": "^1.21.1", "@types/babel__core": "^7.1.19", "@types/babel__generator": "^7.6.4", "@types/babel__traverse": "^7.17.1",