Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ on:
- develop
pull_request:
workflow_dispatch:
inputs:
seed_vr_baselines:
description: 'Seed missing visual-regression baselines for this OS (upload as artifact, does not gate)'
type: boolean
default: false

permissions:
actions: read
Expand Down Expand Up @@ -41,9 +46,71 @@ jobs:
- run: pnpm nx affected -t test
- run: pnpm nx affected -t build

# TODO: Cuando se configuren tests e2e, añadir:
# - run: npx playwright install --with-deps
# - run: pnpm nx affected -t e2e
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0

- name: Fetch develop branch for nx affected
if: github.ref != 'refs/heads/develop'
run: git fetch origin develop:develop --no-tags

- uses: pnpm/action-setup@v5.0.0
with:
version: 10

- uses: actions/setup-node@v6.4.0
with:
node-version: 22
cache: 'pnpm'
registry-url: https://npm.pkg.github.com

- run: pnpm install --frozen-lockfile
env:
NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}

- run: npx playwright install --with-deps chromium

- name: Run e2e
if: ${{ github.event.inputs.seed_vr_baselines != 'true' }}
run: pnpm nx affected -t e2e

- name: Seed missing visual-regression baselines
if: ${{ github.event.inputs.seed_vr_baselines == 'true' }}
# A seeding run writes every absent baseline and reports it as a
# failure by design; the artifact below is the deliverable, so the
# step must not gate.
continue-on-error: true
run: pnpm exec nx run playground-e2e:e2e -- --update-snapshots=missing

- name: Upload visual-regression baselines
if: always()
uses: actions/upload-artifact@v4
with:
name: playground-e2e-vr-baselines
path: apps/playground-e2e/src/parity.spec.ts-snapshots
retention-days: 7
if-no-files-found: ignore

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playground-e2e-playwright-report
path: dist/.playwright/apps/playground-e2e/playwright-report
retention-days: 7
if-no-files-found: ignore

- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playground-e2e-test-results
path: dist/.playwright/apps/playground-e2e/test-output
retention-days: 7
if-no-files-found: ignore

template:
needs: ci
Expand Down
28 changes: 28 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"request": "launch",
"name": "Validate playground (lint+typecheck+test+build)",
"command": "pnpm nx run-many -t lint typecheck test build -p playground",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "E2E playground (Playwright)",
"command": "pnpm nx e2e playground-e2e",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "Serve playground",
"command": "pnpm nx serve playground",
"cwd": "${workspaceFolder}"
},
{
"type": "node-terminal",
"request": "launch",
"name": "Validate packages (lint+typecheck+test+build)",
"command": "pnpm nx run-many -t lint typecheck test build --exclude=playground,playground-e2e",
"cwd": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
Expand Down
61 changes: 27 additions & 34 deletions apps/playground-e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';

// For CI, you may want to set BASE_URL to the deployed application.
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/*
* Dedicated port: 4200 is the default `nx serve playground` port, so a
* developer's already-running dev server (of this app or any other) would be
* silently reused and the suite would screenshot the wrong application.
*/
const baseURL = process.env['BASE_URL'] || 'http://localhost:4300';

/**
* Read environment variables from file.
Expand All @@ -24,45 +28,34 @@ export default defineConfig({
},
/* Run your local dev server before starting the tests */
webServer: {
command: 'pnpm exec nx run playground:serve',
url: 'http://localhost:4200',
command: 'pnpm exec nx run playground:serve --port 4300',
url: 'http://localhost:4300',
reuseExistingServer: true,
cwd: workspaceRoot,
},
/*
* Visual regression baselines are captured on chromium only: cross-browser
* baselines would triple the maintenance burden without adding coverage to
* the flydocs parity gate, which cares about pixel diffs of a single
* rendering engine, not cross-browser compatibility.
*/
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},

{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},

{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},

// Uncomment for mobile browsers support
/* {
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
}, */

// Uncomment for branded browsers
/* {
name: 'Microsoft Edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
{
name: 'Google Chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
} */
],
/*
* Explicit equivalent of Playwright's default screenshot naming
* (`<spec>-snapshots/<arg>-<project>-<platform>.png`), spelled out so the
* per-OS baseline split (darwin locally, linux in CI) is not implicit.
*/
snapshotPathTemplate:
'{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{projectName}-{platform}{ext}',
expect: {
/* Tight tolerance: the flydocs parity gate must catch single-token drift. */
toHaveScreenshot: {
maxDiffPixelRatio: 0.001,
},
},
});
8 changes: 0 additions & 8 deletions apps/playground-e2e/src/example.spec.ts

This file was deleted.

115 changes: 115 additions & 0 deletions apps/playground-e2e/src/parity.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { test, expect, type Page } from '@playwright/test';

/** Number of `<section data-testid="parity-wave-N">` specimens rendered by the `/parity` route. */
const WAVE_COUNT = 5;

/** Flydocs appearances covered by the parity gate: brand theme on, light/dark toggled independently. */
const APPEARANCES = [
{ name: 'light', dark: false },
{ name: 'dark', dark: true },
] as const;

/**
* Persists the `BrandThemeService` / `ThemeService` localStorage contract
* before the app boots, since both services read their preference once in
* the constructor and stamp `data-brand` / `data-theme` on `<html>` during
* the very first render.
*/
async function applyFlydocsAppearance(page: Page, dark: boolean): Promise<void> {
await page.addInitScript((isDark: boolean) => {
window.localStorage.setItem('ff-brand-theme', 'true');
window.localStorage.setItem('ff-dark-mode', String(isDark));
}, dark);
}

/**
* Neutralises animation, transition and caret-blink so repeated renders of
* unchanged markup produce pixel-identical screenshots across runs, and
* removes the sticky shell header, which otherwise floats over the top of
* whichever wave section gets scrolled underneath it during capture.
*/
async function freezeMotion(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.addStyleTag({
content: `
*, *::before, *::after {
animation: none !important;
transition: none !important;
caret-color: transparent !important;
}
.shell__header {
display: none !important;
}
`,
});
}

/** Navigates to the parity catalogue with the given appearance already persisted, then freezes motion. */
async function openParityPage(page: Page, dark: boolean): Promise<void> {
await applyFlydocsAppearance(page, dark);
await page.goto('/parity');
await freezeMotion(page);
}

test.describe('Catalogue parity - Flydocs theme', () => {
test.beforeEach(async ({ page }) => {
// Fixed viewport keeps section wrapping and screenshot dimensions deterministic.
await page.setViewportSize({ width: 1280, height: 720 });
});

for (const appearance of APPEARANCES) {
test.describe(`${appearance.name} appearance`, () => {
test.beforeEach(async ({ page }) => {
await openParityPage(page, appearance.dark);
});

for (let wave = 1; wave <= WAVE_COUNT; wave += 1) {
test(`wave ${wave} matches the baseline`, async ({ page }) => {
const section = page.getByTestId(`parity-wave-${wave}`);
await expect(section).toBeVisible();
await expect(section).toHaveScreenshot(`wave-${wave}-${appearance.name}.png`);
});
}
});
}

test.describe('input focus-ring states (light appearance)', () => {
test.beforeEach(async ({ page }) => {
await openParityPage(page, false);
});

test('default input at rest matches the baseline', async ({ page }) => {
const specimen = page.getByTestId('parity-input-default');
await expect(specimen).toBeVisible();
await expect(specimen).toHaveScreenshot('input-default-rest.png');
});

test('default input on hover matches the baseline', async ({ page }) => {
const specimen = page.getByTestId('parity-input-default');
await expect(specimen).toBeVisible();
await specimen.hover();
await expect(specimen).toHaveScreenshot('input-default-hover.png');
});

test('default input focused matches the baseline (wrapper focus-within ring)', async ({
page,
}) => {
const specimen = page.getByTestId('parity-input-default');
await expect(specimen).toBeVisible();
await specimen.locator('input').focus();
await expect(specimen).toHaveScreenshot('input-default-focused.png');
});

test('error input at rest matches the baseline', async ({ page }) => {
const specimen = page.getByTestId('parity-input-error');
await expect(specimen).toBeVisible();
await expect(specimen).toHaveScreenshot('input-error-rest.png');
});

test('disabled input at rest matches the baseline', async ({ page }) => {
const specimen = page.getByTestId('parity-input-disabled');
await expect(specimen).toBeVisible();
await expect(specimen).toHaveScreenshot('input-disabled-rest.png');
});
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 13 additions & 3 deletions apps/playground/src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<div class="shell">
<header class="shell__header">
<h1 class="shell__title">Firefly Design System</h1>
<ff-button variant="outline" size="sm" (clicked)="theme.toggle()">
{{ theme.dark() ? 'Light mode' : 'Dark mode' }}
</ff-button>
<div class="shell__header-actions">
<ff-button variant="outline" size="sm" (clicked)="brandTheme.toggle()">
{{ brandTheme.flydocs() ? 'Default theme' : 'Flydocs theme' }}
</ff-button>
<ff-button variant="outline" size="sm" (clicked)="theme.toggle()">
{{ theme.dark() ? 'Light mode' : 'Dark mode' }}
</ff-button>
</div>
</header>

<div class="shell__body">
Expand Down Expand Up @@ -49,6 +54,11 @@ <h1 class="shell__title">Firefly Design System</h1>
>{{ p.label }}</a
>
}

<ff-divider />

<span class="shell__nav-group">QA</span>
<a class="shell__nav-link" routerLink="/parity" routerLinkActive="shell__nav-link--active">Parity</a>
</nav>

<main class="shell__main">
Expand Down
5 changes: 5 additions & 0 deletions apps/playground/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ export const appRoutes: Route[] = [
title: 'Empty State · Firefly DS',
loadComponent: () => import('./pages/catalog/empty-state-page').then((m) => m.EmptyStatePage),
},
{
path: 'parity',
title: 'Parity · Firefly DS',
loadComponent: () => import('./pages/parity/parity-page').then((m) => m.ParityPage),
},
{
path: 'patterns',
title: 'Patterns · Firefly DS',
Expand Down
6 changes: 6 additions & 0 deletions apps/playground/src/app/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
font-weight: var(--ff-font-weight-bold);
}

.shell__header-actions {
display: flex;
align-items: center;
gap: var(--ff-spacing-sm);
}

.shell__body {
display: flex;
flex: 1;
Expand Down
2 changes: 2 additions & 0 deletions apps/playground/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { FfButtonComponent, FfDividerComponent } from '@fireflyframework/design-system';

import { CATALOG_COMPONENTS, PATTERN_COMPONENTS } from './shared/catalog-nav';
import { BrandThemeService } from './shared/brand-theme.service';
import { ThemeService } from './shared/theme.service';

/**
Expand All @@ -18,6 +19,7 @@ import { ThemeService } from './shared/theme.service';
})
export class App {
protected readonly theme = inject(ThemeService);
protected readonly brandTheme = inject(BrandThemeService);
protected readonly components = CATALOG_COMPONENTS;
protected readonly patterns = PATTERN_COMPONENTS;
}
Loading
Loading