diff --git a/.gitignore b/.gitignore index 617b69c..c575a09 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ npm-debug.log dist _site packages/*/coverage +/test-results +/playwright-report diff --git a/docs/FIXTURE_VALIDATION.md b/docs/FIXTURE_VALIDATION.md new file mode 100644 index 0000000..62873e1 --- /dev/null +++ b/docs/FIXTURE_VALIDATION.md @@ -0,0 +1,199 @@ +# CSS if() Polyfill Fixture Validation + +This document explains the fixture validation system for the CSS if() polyfill, which ensures that your CSS transformations work correctly in real browser environments. + +## Overview + +The fixture validation system uses **Playwright** to test CSS fixture pairs in a headless Chromium browser. This approach validates that: + +1. **Input CSS** (with `if()` functions) gets properly transformed by the polyfill +2. **Expected CSS** (the desired output) produces the same visual results +3. **Media queries** respond correctly to viewport changes +4. **@supports queries** work as expected + +## How It Works + +### 1. Fixture Structure + +Fixtures come in pairs located in `test/fixtures/`: + +- `*.input.css` - CSS containing `if()` functions +- `*.expected.css` - Expected transformation result + +Example: + +```text +test/fixtures/ +├── basic-media.input.css # if(media(max-width: 768px): 100%; else: 50%) +├── basic-media.expected.css # Standard media query equivalent +├── basic-style.input.css # if(style(--theme): var(--primary); else: blue) +└── basic-style.expected.css # Resolved conditional styles +``` + +### 2. Browser Testing Process + +For each fixture pair, the system: + +1. **Creates an HTML page** with both input and expected CSS +2. **Loads the polyfill** and applies it to the input CSS +3. **Captures computed styles** from the polyfilled version +4. **Switches to expected CSS** and captures those styles +5. **Compares the results** to ensure they match + +### 3. Validation Types + +#### Basic Style Validation + +Compares computed styles for properties like: + +- `color` +- `width` +- `display` +- `backgroundColor` +- `fontSize` +- `margin`, `padding`, `border` + +#### Media Query Testing + +Tests responsive behavior at different viewports: + +- Desktop (1200x800) +- Tablet (768x600) +- Mobile (375x667) + +#### @supports Testing + +Validates CSS feature detection with properties like: + +- `display: grid` +- `display: flex` +- `color: color(display-p3 1 0 0)` + +## Running Tests + +### Command Line + +```bash +# Run all fixture tests +pnpm run test:fixtures + +# Run specific fixture with config +pnpm exec playwright test --config=test/fixtures-validation/fixture-validation.playwright.config.js --grep "basic-media" + +# Run with browser UI (for debugging) +pnpm exec playwright test --config=test/fixtures-validation/fixture-validation.playwright.config.js --ui +``` + +### Using the Helper Script + +```bash +# Run all fixtures +node scripts/validate-fixtures.js + +# Run specific fixture +node scripts/validate-fixtures.js basic-media + +# List available fixtures +node scripts/validate-fixtures.js --list + +# Show help +node scripts/validate-fixtures.js --help +``` + +## Adding New Fixtures + +1. **Create input CSS** with `if()` functions: + + ```css + /* my-feature.input.css */ + .element { + color: if( + supports(color: lab(50% 20 -30)): lab(50% 20 -30) ; else: #blue + ); + } + ``` + +2. **Create expected CSS** with the desired output: + + ```css + /* my-feature.expected.css */ + .element { + color: #blue; + } + @supports (color: lab(50% 20 -30)) { + .element { + color: lab(50% 20 -30); + } + } + ``` + +3. **Test automatically** - the validation will pick up new fixtures + +## Understanding Test Results + +### ✅ Passing Tests + +All computed styles match between polyfill and expected CSS. + +### ❌ Failing Tests + +Common failure reasons: + +1. **Style Mismatch**: Polyfill produces different computed values + + ```text + Property 'width' should match between polyfill and expected CSS + Expected: "400px" + Received: "50%" + ``` + +2. **Media Query Issues**: Responsive behavior doesn't match + + ```text + Property 'width' should match at mobile viewport (375x667) + ``` + +3. **Polyfill Errors**: JavaScript errors in the polyfill code + ```text + Error: if() function parsing failed + ``` + +## Browser Support + +The tests run on: + +- **Chromium** (primary - matches most real-world usage) +- **Firefox** (cross-browser validation) +- **WebKit** (Safari compatibility) + +## Benefits + +This validation system provides: + +1. **Real Browser Testing** - No mocking, actual CSS computation +2. **Visual Accuracy** - Ensures polyfill produces identical rendering +3. **Regression Detection** - Catches breaking changes automatically +4. **Cross-Browser Validation** - Tests on multiple engines +5. **Responsive Testing** - Validates media query behavior +6. **Feature Detection** - Ensures @supports works correctly + +## Troubleshooting + +### Tests Won't Start + +- Ensure Playwright is installed: `npx playwright install` +- Check that fixtures exist in `test/fixtures/` + +### Style Mismatches + +- Check if polyfill is correctly transforming CSS +- Verify expected CSS is accurate +- Test manually in browser to confirm behavior + +### Performance Issues + +- Tests run in parallel by default +- Use `--workers=1` to run sequentially if needed +- Consider reducing viewport testing for faster runs + +This fixture validation system gives you confidence that your CSS `if()` polyfill works correctly across different browsers and scenarios, providing the same visual results as native CSS would. diff --git a/docs/TEST_FIXTURES.md b/docs/TEST_FIXTURES.md index 4aad41c..e930bf4 100644 --- a/docs/TEST_FIXTURES.md +++ b/docs/TEST_FIXTURES.md @@ -70,7 +70,7 @@ This document demonstrates the centralized test fixture system that provides a s ```css .test { - color: if(style(--theme): var(--primary) ; else: #00f); + color: if(style(--theme): var(--primary); else: #00f); } ``` @@ -207,7 +207,9 @@ This document demonstrates the centralized test fixture system that provides a s ```css .responsive { - width: if(media((width >= 768px) and (width <= 1024px)): 50%; else: 100%); + width: if( + media((width >= 768px) and (width <= 1024px)): 50%; else: 100% + ); } ``` diff --git a/package.json b/package.json index 0f726d4..08c04ff 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,10 @@ "prelint:packages": "pnpm run build", "prepare": "husky", "preserve": "pnpm --filter=css-if-polyfill run build", + "pretest:fixtures": "npm run build", "serve": "http-server -p 3000 -o examples/basic-examples.html", - "test": "pnpm --recursive run test" + "test": "pnpm --recursive run test && pnpm run test:fixtures", + "test:fixtures": "playwright test test/fixtures-validation/fixture-validation.test.js --config=test/fixtures-validation/fixture-validation.playwright.config.js" }, "devDependencies": { "@babel/core": "7.28.0", @@ -51,6 +53,7 @@ "@changesets/cli": "2.29.5", "@commitlint/cli": "19.8.1", "@commitlint/config-conventional": "19.8.1", + "@playwright/test": "^1.54.1", "@vitest/coverage-v8": "3.2.4", "http-server": "14.1.1", "husky": "9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 458c98d..32b0b18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@commitlint/config-conventional': specifier: 19.8.1 version: 19.8.1 + '@playwright/test': + specifier: ^1.54.1 + version: 1.54.2 '@vitest/coverage-v8': specifier: 3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.22.0)(terser@5.43.1)(yaml@2.8.1)) @@ -1188,6 +1191,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.54.2': + resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + engines: {node: '>=18'} + hasBin: true + '@publint/pack@0.1.2': resolution: {integrity: sha512-S+9ANAvUmjutrshV4jZjaiG8XQyuJIZ8a4utWmN/vW1sgQ9IfBnPndwkmQYw53QmouOIytT874u65HEmu6H5jw==} engines: {node: '>=18'} @@ -6406,6 +6414,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.54.2': + dependencies: + playwright: 1.54.2 + '@publint/pack@0.1.2': {} '@rollup/plugin-alias@3.1.9(rollup@2.79.2)': diff --git a/scripts/validate-fixtures.js b/scripts/validate-fixtures.js new file mode 100644 index 0000000..8d217bf --- /dev/null +++ b/scripts/validate-fixtures.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +/** + * Fixture Test Runner + * + * This script helps validate CSS if() polyfill fixtures by running them through + * a real browser environment using Playwright. + * + * Usage: + * node scripts/validate-fixtures.js [fixture-name] + * + * Examples: + * node scripts/validate-fixtures.js # Run all fixtures + * node scripts/validate-fixtures.js basic-media # Run specific fixture + */ + +import { execSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, '..', 'test', 'fixtures'); + +function getAvailableFixtures() { + const files = readdirSync(fixturesDir); + const inputFiles = files.filter((file) => file.endsWith('.input.css')); + return inputFiles.map((file) => file.replace('.input.css', '')); +} + +function runFixtureTests(fixtureName = null) { + console.log('🎭 CSS if() Polyfill Fixture Validation'); + console.log('==========================================\n'); + + const availableFixtures = getAvailableFixtures(); + + if (fixtureName) { + if (!availableFixtures.includes(fixtureName)) { + console.error(`❌ Fixture "${fixtureName}" not found.`); + console.log('\nAvailable fixtures:'); + for (const name of availableFixtures) console.log(` - ${name}`); + process.exit(1); + } + + console.log(`🧪 Running fixture: ${fixtureName}\n`); + } else { + console.log(`🧪 Running all ${availableFixtures.length} fixtures:\n`); + for (const name of availableFixtures) console.log(` ✓ ${name}`); + console.log(''); + } + + try { + // Run Playwright tests + const grepPattern = fixtureName + ? `--grep "validates ${fixtureName} fixture"` + : ''; + const command = `npx playwright test test/fixtures-validation/fixture-validation.test.js --config=test/fixtures-validation/fixture-validation.playwright.config.js ${grepPattern}`; + + console.log('🚀 Starting browser-based validation...\n'); + + execSync(command, { + stdio: 'inherit', + cwd: path.join(__dirname, '..') + }); + + console.log('\n✅ All fixture validations passed!'); + console.log('\nThis means:'); + console.log(' • Your polyfill correctly transforms input CSS'); + console.log(' • Browser rendering matches expected output'); + console.log(' • Media queries work responsively'); + console.log(' • @supports queries function properly'); + } catch { + console.error('\n❌ Fixture validation failed!'); + console.error('Please check the test output above for details.'); + process.exit(1); + } +} + +// Parse command line arguments +const fixtureName = process.argv[2]; + +// Show help if requested +if (fixtureName === '--help' || fixtureName === '-h') { + console.log('CSS if() Polyfill Fixture Validator\n'); + console.log('Usage:'); + console.log(' node scripts/validate-fixtures.js [fixture-name]\n'); + console.log('Examples:'); + console.log( + ' node scripts/validate-fixtures.js # Run all fixtures' + ); + console.log( + ' node scripts/validate-fixtures.js basic-media # Run specific fixture' + ); + console.log( + ' node scripts/validate-fixtures.js --list # List available fixtures\n' + ); + process.exit(0); +} + +// List fixtures if requested +if (fixtureName === '--list' || fixtureName === '-l') { + console.log('Available fixtures:'); + for (const name of getAvailableFixtures()) console.log(` ${name}`); + process.exit(0); +} + +// Run the tests +runFixtureTests(fixtureName); diff --git a/test/fixtures-validation/fixture-validation.playwright.config.js b/test/fixtures-validation/fixture-validation.playwright.config.js new file mode 100644 index 0000000..269ed2d --- /dev/null +++ b/test/fixtures-validation/fixture-validation.playwright.config.js @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test'; +import process from 'node:process'; + +/** + * Playwright Configuration for CSS if() Polyfill Fixture Validation + * + * This configuration is specifically for validating CSS fixture pairs + * by testing them in real browser environments. It's separate from any + * other Playwright tests that might exist in the project. + * + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: '../..', + testMatch: '**/test/fixtures-validation/fixture-validation.test.js', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: Boolean(process.env.CI), + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry' + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ] + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/test/fixtures-validation/fixture-validation.test.js b/test/fixtures-validation/fixture-validation.test.js new file mode 100644 index 0000000..98b1a5c --- /dev/null +++ b/test/fixtures-validation/fixture-validation.test.js @@ -0,0 +1,344 @@ +/** + * Fixture Validation Tests + * + * This test suite validates CSS fixture pairs by: + * 1. Loading the input CSS with the polyfill + * 2. Loading the expected CSS directly + * 3. Comparing the computed styles in a real browser environment + */ + +import { expect, test } from '@playwright/test'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, '..', 'fixtures'); +const polyfillUmdPath = path.join( + __dirname, + '..', + '..', + 'packages', + 'css-if-polyfill', + 'dist', + 'index.umd.js' +); + +// Read polyfill UMD build once at module load +const polyfillUmdJs = readFileSync(polyfillUmdPath, 'utf8'); + +// Get all fixture pairs +const getFixturePairs = () => { + const files = readdirSync(fixturesDir); + const inputFiles = files.filter((file) => file.endsWith('.input.css')); + + return inputFiles.map((inputFile) => { + const baseName = inputFile.replace('.input.css', ''); + const expectedFile = `${baseName}.expected.css`; + + if (!files.includes(expectedFile)) { + throw new Error(`Missing expected file for ${inputFile}`); + } + + return { + name: baseName, + inputFile: path.join(fixturesDir, inputFile), + expectedFile: path.join(fixturesDir, expectedFile), + inputCSS: readFileSync(path.join(fixturesDir, inputFile), 'utf8'), + expectedCSS: readFileSync( + path.join(fixturesDir, expectedFile), + 'utf8' + ) + }; + }); +}; + +/** + * Test media query responsive behavior + */ +async function testMediaQueryBehavior(page) { + // Test at different viewport sizes + const viewports = [ + { width: 1200, height: 800, name: 'desktop' }, + { width: 768, height: 600, name: 'tablet' }, + { width: 375, height: 667, name: 'mobile' } + ]; + + /* eslint-disable no-await-in-loop */ + for (const viewport of viewports) { + await page.setViewportSize(viewport); + await page.waitForTimeout(100); // Allow media queries to apply + + // Re-enable polyfill styles for this test + await page.evaluate(() => { + globalThis.document.querySelector('#polyfill-styles').disabled = + false; + globalThis.document.querySelector('#expected-styles').disabled = + true; + }); + + await page.waitForTimeout(50); + + const polyfillStyles = await page.evaluate(() => { + const testElement = + globalThis.document.querySelector('.test, .responsive'); + if (!testElement) return null; + + const computed = globalThis.getComputedStyle(testElement); + return { + width: computed.width, + color: computed.color, + display: computed.display + }; + }); + + // Switch to expected styles + await page.evaluate(() => { + globalThis.document.querySelector('#polyfill-styles').disabled = + true; + globalThis.document.querySelector('#expected-styles').disabled = + false; + }); + + await page.waitForTimeout(50); + + const expectedStyles = await page.evaluate(() => { + const testElement = + globalThis.document.querySelector('.test, .responsive'); + if (!testElement) return null; + + const computed = globalThis.getComputedStyle(testElement); + return { + width: computed.width, + color: computed.color, + display: computed.display + }; + }); + + // Compare styles at this viewport + if (polyfillStyles && expectedStyles) { + for (const [property, expectedValue] of Object.entries( + expectedStyles + )) { + expect( + polyfillStyles[property], + `Property '${property}' should match at ${viewport.name} viewport (${viewport.width}x${viewport.height})` + ).toBe(expectedValue); + } + } + } + /* eslint-enable no-await-in-loop */ +} + +/** + * Test @supports query behavior + */ +async function testSupportsQueryBehavior(page) { + // Test different CSS support scenarios + const supportTests = [ + { property: 'display', value: 'grid' }, + { property: 'display', value: 'flex' }, + { property: 'color', value: 'color(display-p3 1 0 0)' } + ]; + + /* eslint-disable no-await-in-loop */ + for (const supportTest of supportTests) { + const supportsResult = await page.evaluate( + ({ property, value }) => globalThis.CSS.supports(property, value), + supportTest + ); + + // Re-test styles knowing the actual support status + await page.evaluate(() => { + globalThis.document.querySelector('#polyfill-styles').disabled = + false; + globalThis.document.querySelector('#expected-styles').disabled = + true; + }); + + await page.waitForTimeout(50); + + const polyfillStyles = await page.evaluate(() => { + const testElement = globalThis.document.querySelector('.test'); + if (!testElement) return null; + + const computed = globalThis.getComputedStyle(testElement); + return { + color: computed.color, + display: computed.display, + width: computed.width + }; + }); + + // This test mainly ensures the polyfill doesn't break @supports functionality + expect(polyfillStyles).toBeTruthy(); + expect(supportsResult).toBeDefined(); + } + /* eslint-enable no-await-in-loop */ +} + +// Test configuration for different scenarios +test.describe('CSS if() Polyfill Fixture Validation', () => { + test.beforeEach(async ({ page }) => { + // Set up server to serve static files + await page.route('/packages/**', async (route) => { + const url = route.request().url(); + const filePath = url.replace( + /^.*\/packages\//, + path.join(__dirname, '..', '..', 'packages') + ); + + try { + const content = readFileSync(filePath, 'utf8'); + const contentType = filePath.endsWith('.js') + ? 'application/javascript' + : 'text/plain'; + + await route.fulfill({ + status: 200, + contentType, + body: content + }); + } catch { + await route.fulfill({ + status: 404, + body: `File not found: ${filePath}` + }); + } + }); + }); + + // Dynamically generate tests for all fixture pairs + const fixturePairs = getFixturePairs(); + + for (const fixture of fixturePairs) { + test(`validates ${fixture.name} fixture`, async ({ page }) => { + await testFixture(page, fixture.name); + }); + } +}); + +async function testFixture(page, fixtureName) { + // Read fixture files + const inputCSS = readFileSync( + path.join(fixturesDir, `${fixtureName}.input.css`), + 'utf8' + ); + const expectedCSS = readFileSync( + path.join(fixturesDir, `${fixtureName}.expected.css`), + 'utf8' + ); + + // Create HTML page with polyfill and test content + const htmlContent = ` + + + + + CSS if() Polyfill Test - ${fixtureName} + + + + +
Test Element
+
+
Nested Test
+
+ + + + `; + + // Set up the page content + await page.setContent(htmlContent, { + waitUntil: 'domcontentloaded' + }); + + // Wait for polyfill to be ready + await page.waitForFunction(() => globalThis.polyfillReady === true, { + timeout: 5000 + }); + + // Give polyfill time to process + await page.waitForTimeout(100); + + // Get computed styles after polyfill processing + const polyfillStyles = await page.evaluate(() => { + const testElement = globalThis.document.querySelector('.test'); + if (!testElement) return null; + + const computed = globalThis.getComputedStyle(testElement); + + return { + color: computed.color, + width: computed.width, + display: computed.display, + backgroundColor: computed.backgroundColor, + fontSize: computed.fontSize, + margin: computed.margin, + padding: computed.padding, + border: computed.border + }; + }); + + // Now test with expected CSS + await page.evaluate(() => { + // Disable polyfill styles and enable expected styles + globalThis.document.querySelector('#polyfill-styles').disabled = true; + globalThis.document.querySelector('#expected-styles').disabled = false; + }); + + // Wait a bit for styles to apply + await page.waitForTimeout(50); + + // Get computed styles with expected CSS + const expectedStyles = await page.evaluate(() => { + const testElement = globalThis.document.querySelector('.test'); + if (!testElement) return null; + + const computed = globalThis.getComputedStyle(testElement); + + return { + color: computed.color, + width: computed.width, + display: computed.display, + backgroundColor: computed.backgroundColor, + fontSize: computed.fontSize, + margin: computed.margin, + padding: computed.padding, + border: computed.border + }; + }); + + // Compare the styles + expect(polyfillStyles).toBeTruthy(); + expect(expectedStyles).toBeTruthy(); + + // Compare each style property + for (const [property, expectedValue] of Object.entries(expectedStyles)) { + expect( + polyfillStyles[property], + `Property '${property}' should match between polyfill and expected CSS` + ).toBe(expectedValue); + } + + // Test responsive behavior for media query fixtures + if (fixtureName.includes('media')) { + await testMediaQueryBehavior(page); + } + + // Test supports() functionality + if (fixtureName.includes('supports')) { + await testSupportsQueryBehavior(page); + } +}