Skip to main content

Fix FSE Group Block Layout Property Overriding Custom CSS

· 3 min read

TL;DR

WordPress FSE Group blocks with layout property automatically generate is-layout-* CSS classes that have higher specificity than custom CSS, causing size settings to be ignored. Solution: 1) Use "layout":{"type":"default"} in block annotation to avoid extra layout classes; 2) Use !important in CSS to force override; 3) Key: Add padding: 0 !important to clear the Group block's default padding.

Problem

Timeline component year dots should display as 80px circles, but appear as ellipses:

<!-- Size settings in block annotation -->
<!-- wp:group {"style":{"dimensions":{"width":"80px","height":"80px"}},"layout":{"type":"flex",...}} -->
/* Custom CSS */
.cclee-timeline-dot {
width: 80px;
height: 80px;
border-radius: 50%;
}

Neither adjusting CSS nor block attributes fixed the stretched dots.

Root Cause

WordPress FSE Group blocks automatically add layout-related CSS classes based on the layout property:

<div class="wp-block-group cclee-timeline-dot is-layout-flow">

These is-layout-* classes come from WordPress core stylesheets and override custom CSS. Additionally, Group blocks have default padding that increases element dimensions.

Key issues:

  1. layout: {"type": "flex"} generates is-layout-flex class, causing children to be stretched by flexbox
  2. style.dimensions in block annotation becomes inline style, but gets overridden by layout class styles
  3. Group block default padding adds to actual element size

Solution

1. Modify Block Annotation with Default Layout

<!-- wp:group {"className":"cclee-timeline-dot","style":{"border":{"radius":"50%"}},"backgroundColor":"accent","textColor":"base","layout":{"type":"default"}} -->
<div class="wp-block-group cclee-timeline-dot has-base-color has-accent-background-color has-text-color has-background" style="border-radius:50%">

Remove style.dimensions and complex flex layout, use "layout":{"type":"default"} instead.

2. CSS Override + Clear Default Padding

/* Timeline: Fixed circle dot */
.wp-block-group.cclee-timeline-dot {
width: 80px !important;
height: 80px !important;
min-width: 80px !important;
min-height: 80px !important;
flex-shrink: 0 !important;
aspect-ratio: unset !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
align-self: center !important;
box-sizing: border-box !important;
text-align: center !important;
padding: 0 !important; /* Key: clear default padding */
}

.wp-block-group.cclee-timeline-dot p {
margin: 0 !important;
white-space: nowrap !important;
line-height: 1 !important;
overflow: visible !important;
}

3. Use :has() to Control Parent Container

Prevent parent Column from being stretched by flexbox:

.wp-block-columns .wp-block-column:has(.cclee-timeline-dot) {
flex-shrink: 0 !important;
flex-basis: 100px !important;
width: 100px !important;
}

Key Finding

padding: 0 !important is the final solution. Group block's default padding stretches the element—even with width/height set, the actual rendered size exceeds expectations.


Interested in similar solutions? Contact us

Fix WordPress FSE Theme Footer Text Visibility - WCAG Contrast Issue

· 3 min read

While developing a WordPress FSE enterprise theme for a client, I discovered the Footer block text was nearly invisible across multiple Style Variations. This post documents the complete fix process - from WCAG contrast diagnosis to introducing semantic colors and handling global styles override.

TL;DR

Problem: FSE theme's contrast color token has conflicting semantics. In light themes, contrast ≈ light gray ≈ base (white), resulting in Footer contrast ratio of only 1.05:1.

Solution:

  1. Introduce surface semantic color for dark block backgrounds
  2. Delete override styles in wp_global_styles
  3. Add surface definition to all Style Variations

Result: Contrast ratio improved from 1.05:1 to 15.8:1 (WCAG AAA grade).

Problem

Footer block uses backgroundColor="contrast" + textColor="base":

<!-- wp:group {"backgroundColor":"contrast","textColor":"base"} -->
<div class="has-base-color has-contrast-background-color">
Footer content
</div>

Under default theme, Footer text is nearly invisible:

CombinationForegroundBackgroundContrastWCAG
Footer text#ffffff (base)#f8fafc (contrast)1.05:1❌ Fail
Footer link#f59e0b (accent)#f8fafc (contrast)1.78:1❌ Fail

WCAG AA standard requires ≥ 4.5:1 for normal text. Current state is far below standard.

Root Cause Analysis

1. Conflicting contrast Semantics

contrast was designed as "background color that contrasts with base", but semantics conflict across themes:

VariationbasecontrastExpected vs Actual
Default (light)#ffffff white#f8fafc light grayExpected dark, actual light
Tech (dark)#0f0f1a deep black#1e1e2e deep purpleExpected light, actual dark

Footer Pattern assumes contrast is a dark background, but in 5/6 Style Variations it's light.

2. Missing Semantic Color for Dark Blocks

Original design system only had one "contrast" color, without distinguishing:

  • Light contrast blocks (CTA Banner, etc.)
  • Dark contrast blocks (Footer, dark Hero, etc.)

Solution

Step 1: Introduce surface Semantic Color

Add surface token in theme.json for dark block backgrounds:

{
"slug": "surface",
"color": "#0f172a",
"name": "Surface"
}

Step 2: Update All Style Variations

Each variation defines its own surface color (usually equals primary):

// styles/commerce.json
{ "slug": "surface", "color": "#1f2937", "name": "Surface" }

// styles/nature.json
{ "slug": "surface", "color": "#14532d", "name": "Surface" }

// styles/tech.json (dark theme)
{ "slug": "surface", "color": "#1e1e2e", "name": "Surface" }
<!-- wp:group {"backgroundColor":"surface","textColor":"base"} -->
<div class="has-base-color has-surface-background-color">
Footer content
</div>

Step 4: Delete Global Styles Override

Colors still not working after modifying theme.json? Check global styles:

# Check if global styles exist
docker exec wp_cli wp post list --post_type=wp_global_styles --fields=ID,post_title --allow-root

# Delete global styles
docker exec wp_cli wp post delete <ID> --force --allow-root
docker exec wp_cli wp cache flush --allow-root

Reason: color.palette in wp_global_styles completely overrides (not merges) theme.json's palette.

Results

Variationsurface + base ContrastWCAG Grade
Default15.8:1✅ AAA
Commerce13.1:1✅ AAA
Industrial12.6:1✅ AAA
Professional9.9:1✅ AAA
Nature10.8:1✅ AAA
Tech11.5:1✅ AAA

Color Semantics Summary

TokenPurpose
primaryBrand color (Logo, primary button)
secondarySecondary elements
accentCall to action (CTA, links)
basePage main background
contrastLight contrast block background
surfaceDark block background (Footer, dark CTA) ← New

Interested in similar solutions? Get in touch

Fix WordPress FSE Pattern Block Validation Errors - 5 Common Causes

· 4 min read

Encountered frequent Block Pattern validation failures while developing a WordPress FSE theme for a client. This article summarizes 5 common causes and solutions.

TL;DR

Block validation failures are usually caused by one of: undefined color slugs, duplicate JSON keys, Style Variation palette override, HTML attribute mismatch with block comments, or global styles overriding theme.json. Check each one systematically.

Problem

Patterns show a red warning in the editor:

Block contains unexpected or invalid content

After attempting to recover the block, it may work temporarily, but the issue returns after refresh.


Cause 1: Undefined Color Slug

Root Cause

Pattern block attributes reference color slugs that don't exist in theme.json:

<!-- Error: neutral-text doesn't exist -->
<!-- wp:paragraph {"textColor":"neutral-text"} -->
<p class="has-neutral-text-color">...</p>

Solution

  1. Open theme.json and check all colors defined in settings.color.palette
  2. Replace invalid slugs in your Pattern with valid ones
# Batch replacement example
cd patterns/
sed -i 's/"neutral-text"/"neutral-500"/g' *.php
sed -i 's/has-neutral-text-color/has-neutral-500-color/g' *.php

Valid slugs reference: primary, secondary, accent, base, contrast, neutral-50 through neutral-900


Cause 2: Duplicate JSON Key

Root Cause

Block comment JSON has duplicate keys at the same level (common when copy-pasting):

// Error: two style keys
{"style":{"typography":{...}},"style":{"spacing":{...}}}

JSON specification doesn't allow duplicate keys; parser behavior is undefined.

Solution

Merge into a single key:

// Correct
{"style":{"typography":{...},"spacing":{...}}}

Debug command:

# Search for potentially duplicate keys
grep -n ',"style":{' patterns/*.php | head -20

Cause 3: Style Variation Palette Override

Root Cause

color.palette in styles/*.json completely overrides (doesn't merge) the parent theme palette.

When a Pattern references neutral-500, but the current Style Variation doesn't define it, validation fails.

Solution

Each Style Variation must include the complete neutral series:

// styles/ocean.json
{
"version": 3,
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#0d9488", "name": "Primary" },
{ "slug": "secondary", "color": "#0f766e", "name": "Secondary" },
{ "slug": "accent", "color": "#f59e0b", "name": "Accent" },
{ "slug": "base", "color": "#f8fafc", "name": "Base" },
{ "slug": "contrast", "color": "#0f172a", "name": "Contrast" },
{ "slug": "neutral-50", "color": "#fafafa", "name": "Neutral 50" },
{ "slug": "neutral-100", "color": "#f5f5f5", "name": "Neutral 100" },
{ "slug": "neutral-200", "color": "#e5e5e5", "name": "Neutral 200" },
{ "slug": "neutral-300", "color": "#d4d4d4", "name": "Neutral 300" },
{ "slug": "neutral-400", "color": "#a3a3a3", "name": "Neutral 400" },
{ "slug": "neutral-500", "color": "#737373", "name": "Neutral 500" },
{ "slug": "neutral-600", "color": "#525252", "name": "Neutral 600" },
{ "slug": "neutral-700", "color": "#404040", "name": "Neutral 700" },
{ "slug": "neutral-800", "color": "#262626", "name": "Neutral 800" },
{ "slug": "neutral-900", "color": "#171717", "name": "Neutral 900" }
]
}
}
}

Key point: Neutral series color values must match theme.json exactly; only change brand colors.


Cause 4: HTML Attribute Mismatch with Block Comments

Root Cause

This is the most subtle issue. WordPress save function has strict requirements for generated HTML.

Issue 4.1: Incorrect class Order

WordPress generates classes in a fixed order:

has-border-color has-{slug}-border-color has-{slug}-background-color has-background

Wrong order in hand-written HTML causes validation failure.

Issue 4.2: Background Color Attribute Mixing

Background color must use backgroundColor attribute, not style.color.background:

<!-- Error: mixing causes invalid CSS generation -->
<!-- wp:group {"style":{"color":{"background":"#f5f5f5"}}} -->

<!-- Correct -->
<!-- wp:group {"backgroundColor":"neutral-100"} -->

Issue 4.3: Border Style Attribute Order

border-width must come before border-style:

<!-- Correct -->
<div style="border-width:1px;border-style:solid;border-radius:8px;">

Solution

Best practice: Copy block code from the editor, don't hand-write HTML class and style.

  1. Configure the block in the editor
  2. Switch to Code Editor view
  3. Copy the complete block comment + HTML
  4. Paste into Pattern file

Correct example:

<!-- wp:group {"backgroundColor":"accent","borderColor":"neutral-200","style":{"border":{"radius":"8px","width":"1px","style":"solid"}}} -->
<div class="wp-block-group has-border-color has-neutral-200-border-color has-accent-background-color has-background" style="border-width:1px;border-style:solid;border-radius:8px;">
<!-- content -->
</div>
<!-- /wp:group -->

Cause 5: Global Styles Override theme.json

Root Cause

Custom styles saved by Site Editor are stored in wp_global_styles CPT with higher priority than theme.json.

After modifying theme.json, the frontend still shows old values because global styles override theme defaults.

Debug

# Check if global styles exist
wp post list --post_type=wp_global_styles --fields=ID,post_title --allow-root

# View global styles content
wp post get <ID> --fields=post_content --allow-root

Solution

# Delete global styles
wp post delete <ID> --force --allow-root

# Clear cache
wp cache flush --allow-root

Prevention: Avoid using Site Editor for custom styles during development. Manage all configuration through theme.json.


Debug Flow Summary

Block validation failed

├─→ Check if color slugs are defined in theme.json

├─→ Check JSON for duplicate keys

├─→ Check all Style Variations have complete palettes

├─→ Check HTML class/style matches block comments

└─→ Check if wp_global_styles overrides theme.json

Interested in similar projects? Get in touch

Installing WP-CLI in Docker WordPress Container

· 2 min read

TL;DR

The official WordPress Docker image doesn't include WP-CLI. Add a command configuration in docker-compose.yml to auto-install WP-CLI on container startup—no manual container entry required.

Problem

Running wp command inside WordPress Docker container:

docker exec -it wordpress_container wp --version

Returns:

bash: wp: command not found

Root Cause

The official WordPress Docker image is built on php:apache with a focus on minimal size. WP-CLI is a separate CLI tool that requires additional installation—it's not included in the default image.

Solution

Add a command to the WordPress service in docker-compose.yml to auto-install WP-CLI on startup:

services:
wordpress:
image: wordpress:latest
volumes:
- ./wordpress:/var/www/html
command: >
bash -c "curl -sO https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar &&
chmod +x wp-cli.phar &&
mv wp-cli.phar /usr/local/bin/wp &&
docker-entrypoint.sh apache2-foreground"

Key points:

  1. curl -sO - Silently download WP-CLI phar package
  2. chmod +x - Add execute permission
  3. mv ... /usr/local/bin/wp - Move to PATH directory for global access
  4. docker-entrypoint.sh apache2-foreground - Execute original image entrypoint to start Apache

Verify after restart:

docker-compose down
docker-compose up -d
docker exec -it wordpress_container wp --version
# Output: WP-CLI 2.x.x

Interested in similar solutions? Contact us

Unify Multiple Search APIs with Abstract Class, Return Errors Instead of Raising

· 4 min read

Encountered this issue while building an AI Agent platform for a client: needed to support multiple search providers (Tavily, Serper, Brave, Bing) while ensuring tool call failures don't interrupt the Agent's conversation flow.

TL;DR

  1. Define SearchProvider abstract base class + SearchResult data model for unified interface and output
  2. Each provider inherits the base class, implements search() method with field mapping
  3. Key design: Return SearchResult with error info on failure, never raise exceptions

The Problem

Direct calls to different search APIs look like this:

# Tavily: POST request, results[].url
response = await client.post("https://api.tavily.com/search", ...)

# Serper: POST request, organic[].link
response = await client.post("https://google.serper.dev/search", ...)

# Brave: GET request, web.results[].description
response = await client.get("https://api.search.brave.com/res/v1/web/search", ...)

# Bing: GET request, webPages.value[].snippet
response = await client.get("https://api.bing.microsoft.com/v7.0/search", ...)

Issues:

  1. Request methods, auth headers, and response structures vary
  2. Switching providers requires changing caller code
  3. raise Exception interrupts AI Agent's streaming conversation

Root Cause

  1. Missing abstraction layer: Caller directly depends on concrete implementations, violating dependency inversion
  2. Inconsistent error handling: Exceptions propagate up the call stack, crashing the entire streaming flow

For AI Agent tool calls, the Agent needs to decide whether to retry, use another tool, or explain to the user—not just crash.

Solution

1. Define Abstract Base Class and Data Model

# base.py
from abc import ABC, abstractmethod
from typing import List
from pydantic import BaseModel


class SearchResult(BaseModel):
"""Unified search result."""
title: str
link: str
snippet: str


class SearchProvider(ABC):
"""Base class for search providers."""

def __init__(self, api_key: str):
self.api_key = api_key

@abstractmethod
async def search(self, query: str, max_results: int = 5) -> List[SearchResult]:
"""Execute search and return results."""
pass

2. Implement Concrete Providers

Tavily (AI-optimized search, supports rate limit / quota error codes):

# tavily.py
import httpx
import logging
from typing import List
from .base import SearchProvider, SearchResult

logger = logging.getLogger(__name__)


class TavilySearch(SearchProvider):
"""Tavily Search API implementation."""

async def search(self, query: str, max_results: int = 5) -> List[SearchResult]:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
"https://api.tavily.com/search",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"query": query,
"max_results": max_results,
"search_depth": "basic"
}
)

# Return SearchResult on error, never raise
if response.status_code == 429:
return [SearchResult(
title="Rate Limited",
link="",
snippet="Search quota exceeded. Please try again later."
)]

if response.status_code == 401:
return [SearchResult(
title="Auth Error",
link="",
snippet="Search API key is invalid."
)]

if response.status_code == 402:
return [SearchResult(
title="Quota Exceeded",
link="",
snippet="Monthly search quota depleted."
)]

response.raise_for_status()
data = response.json()

# Field mapping: Tavily's url -> unified link
results = []
for item in data.get("results", [])[:max_results]:
results.append(SearchResult(
title=item.get("title", ""),
link=item.get("url", ""),
snippet=item.get("content", "")
))
return results

except httpx.TimeoutException:
logger.warning(f"Tavily API timeout: {query[:50]}")
return [SearchResult(title="Timeout", link="", snippet="Search timed out.")]
except Exception as e:
logger.error(f"Tavily search error: {e}")
return [SearchResult(title="Error", link="", snippet=f"Search failed: {str(e)}")]

Serper (Google Search API):

# serper.py
class SerperSearch(SearchProvider):
"""Serper (Google Search) API implementation."""

async def search(self, query: str, max_results: int = 5) -> List[SearchResult]:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
response = await client.post(
"https://google.serper.dev/search",
headers={"X-API-KEY": self.api_key, "Content-Type": "application/json"},
json={"q": query, "num": max_results}
)

if response.status_code == 401:
return [SearchResult(title="Auth Error", link="", snippet="Serper API key is invalid.")]

response.raise_for_status()
data = response.json()

# Field mapping: Serper's organic[].link -> unified link
results = []
for item in data.get("organic", [])[:max_results]:
results.append(SearchResult(
title=item.get("title", ""),
link=item.get("link", ""),
snippet=item.get("snippet", "")
))
return results

except httpx.TimeoutException:
return [SearchResult(title="Timeout", link="", snippet="Search timed out.")]
except Exception as e:
return [SearchResult(title="Error", link="", snippet=f"Search failed: {str(e)}")]

Brave and Bing implementations are similar, differing in request method and response field mapping.

3. Caller Usage

# Depend on abstraction only
async def execute_search(provider: SearchProvider, query: str) -> List[SearchResult]:
results = await provider.search(query)

# Check for errors (via title or snippet)
if results and not results[0].link:
error_msg = results[0].snippet
# Agent can decide next action based on error info
return f"Search failed: {error_msg}"

return results


# Switch providers by changing instance only
provider = TavilySearch(api_key="xxx")
# provider = SerperSearch(api_key="xxx")
results = await execute_search(provider, "Python async best practices")

Key Design Decisions

DecisionReason
Return SearchResult on error instead of raiseAI Agent conversations are streaming flows; exceptions interrupt everything
Use Pydantic BaseModel for outputAuto-validation + IDE hints + JSON serialization
Use ABC instead of ProtocolNeed shared __init__ logic (api_key storage)
Unified 15-second timeoutSearch is UX-critical; can't be too slow

Interested in similar solutions? Get in touch

Full API Mocking with Playwright page.route()

· 4 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Use page.route() to intercept all API requests and return predefined mock data. Tests don't depend on real backend, can run stably in any environment, and avoid side effects like creating/deleting data.

Problem

E2E tests calling real API:

// ❌ Depends on real backend
test('create agent', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

// Click create button
await authenticatedPage.click('button:has-text("Create")')

// Fill form
await authenticatedPage.fill('input[name="name"]', 'Test Agent')
await authenticatedPage.click('button[type="submit"]')

// Wait for API response
await authenticatedPage.waitForTimeout(2000)

// Verify... but what if backend is down? DB connection failed?
})

Problems:

  1. Depends on backend state — Tests fail when backend is down
  2. Data side effects — Each run creates real data
  3. Not repeatable — Data changes cause assertion failures
  4. CI environment issues — Need full backend service running

Solution

1. Define Mock Data

// e2e/fixtures.ts
export const mockAgents = [
{
agent_id: 'agent-1',
user_id: 'test-user-id',
name: 'Test Agent 1',
skills: [],
mcp_tools: [],
llm_config: { provider: 'anthropic', model: 'claude-3-5-sonnet-20241022' },
risk_threshold: 'medium',
auto_confirm_low: true,
created_at: '2024-01-01T00:00:00Z',
},
]

export const mockSkills = [
{
skill_id: 'skill-1',
owner_id: 'test-user-id',
name: 'Test Skill',
system_prompt: 'You are helpful.',
is_public: false,
is_own: true,
},
]

2. Setup API Mock

export async function setupMockApi(page: Page) {
// Mock GET /api/agents
await page.route('**/api/agents', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockAgents),
})
} else if (route.request().method() === 'POST') {
// Simulate creation
const body = route.request().postDataJSON()
const newAgent = {
agent_id: `agent-${Date.now()}`,
user_id: 'test-user-id',
name: body.name,
created_at: new Date().toISOString(),
...body,
}
mockAgents.push(newAgent)
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(newAgent),
})
}
})

// Mock GET/PATCH/DELETE /api/agents/:id
await page.route('**/api/agents/*', async (route) => {
const url = route.request().url()
const match = url.match(/\/api\/agents\/([^/]+)/)
const agentId = match?.[1]

if (route.request().method() === 'GET') {
const agent = mockAgents.find((a) => a.agent_id === agentId)
await route.fulfill({
status: agent ? 200 : 404,
contentType: 'application/json',
body: JSON.stringify(agent || { error: 'Not found' }),
})
} else if (route.request().method() === 'DELETE') {
const index = mockAgents.findIndex((a) => a.agent_id === agentId)
if (index !== -1) mockAgents.splice(index, 1)
await route.fulfill({ status: 204 })
}
})

// Mock /api/skills
await page.route('**/api/skills**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockSkills),
})
})

// Mock /api/health
await page.route('**/api/health', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
})
})
}

3. Use in Fixture

export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await setupMockApi(page) // Intercept all API
await use(page)
},
})

4. Use in Tests

// e2e/dashboard.spec.ts
import { test, expect, mockAgents } from './fixtures'

test('should display agent list', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

// API automatically mocked, returns mockAgents
const agentCards = authenticatedPage.locator('[data-testid="agent-card"]')

// Assertion based on known mock data
await expect(agentCards).toHaveCount(mockAgents.length)
})

test('should create new agent', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

await authenticatedPage.click('button:has-text("Create")')

const dialog = authenticatedPage.locator('[role="dialog"]')
await dialog.locator('input[name="name"]').fill('New Agent')
await dialog.locator('button[type="submit"]').click()

// POST /api/agents mocked, returns 201
await authenticatedPage.waitForTimeout(500)

// Verify UI update
await expect(authenticatedPage.locator('text=New Agent')).toBeVisible()
})

Core Techniques

URL Matching Patterns

// Exact match
await page.route('**/api/agents', handler)

// Wildcard match
await page.route('**/api/agents/**', handler)

// Regex match
await page.route(/\/api\/agents\/\d+/, handler)

Reading Request Body

await page.route('**/api/agents', async (route) => {
const body = route.request().postDataJSON()
console.log('Request body:', body)

// Return different responses based on request content
if (body.name === 'error-test') {
await route.fulfill({ status: 400, body: JSON.stringify({ error: 'Bad request' }) })
} else {
await route.fulfill({ status: 201, body: JSON.stringify({ id: 'new-id', ...body }) })
}
})

Simulating Error Scenarios

// Network error
await page.route('**/api/agents', (route) => route.abort('failed'))

// Timeout
await page.route('**/api/agents', async (route) => {
await new Promise((r) => setTimeout(r, 30000))
route.continue()
})

// 500 error
await page.route('**/api/agents', (route) =>
route.fulfill({ status: 500, body: JSON.stringify({ error: 'Internal error' }) })
)

Partial Pass-through

// Only mock specific API, pass through others
await page.route('**/api/**', async (route) => {
const url = route.request().url()

if (url.includes('/api/agents')) {
await route.fulfill({ status: 200, body: JSON.stringify(mockAgents) })
} else {
await route.continue() // Other APIs use real requests
}
})

Mock Data Management

// Centralize all mock data
// e2e/fixtures.ts
export const mockData = {
agents: [...],
skills: [...],
apiKeys: [...],
tasks: [...],
}

// Reset before each test
test.beforeEach(() => {
Object.assign(mockData, JSON.parse(JSON.stringify(originalMockData)))
})

Key Principles

  1. Mock all external dependencies — API, OAuth, third-party services
  2. Match real data structures — Mock data should match API contract
  3. Cover success and failure — Test 200/400/500 scenarios
  4. Isolate test data — Each test uses independent mock data copy

Interested in similar solutions? Contact us

Skip Login in Playwright Tests with Custom Fixtures

· 3 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Use Playwright's test.extend() to create a custom fixture that injects auth token into localStorage via page.addInitScript() before page load. Tests use authenticatedPage instead of page, automatically getting logged-in state without repeating login in each test.

Problem

E2E tests need to verify pages behind authentication:

// ❌ Every test goes through login
test('dashboard', async ({ page }) => {
await page.goto('/login')
await page.fill('input[name="email"]', 'test@example.com')
await page.fill('input[name="password"]', 'password')
await page.click('button[type="submit"]')
await page.waitForURL('/dashboard')
// Finally can start testing...
await expect(page.locator('h1')).toBeVisible()
})

Problems:

  1. Repeated login in every test — Wastes time, slows CI
  2. Depends on real auth service — Tests fail when Supabase Auth is unavailable
  3. State pollution between tests — Login state may affect other tests

Solution

1. Create Custom Fixture

// e2e/fixtures.ts
import { test as base, expect, Page } from '@playwright/test'

// Mock user data
export const mockUser = {
id: 'test-user-id',
email: 'test@example.com',
aud: 'authenticated',
role: 'authenticated',
}

export const mockAccessToken = 'mock-access-token-for-testing'

// Inject auth token into localStorage
export async function setupMockAuth(page: Page) {
await page.addInitScript(
({ user, accessToken }) => {
const mockSession = {
access_token: accessToken,
refresh_token: 'mock-refresh-token',
expires_in: 3600,
expires_at: Math.floor(Date.now() / 1000) + 3600,
token_type: 'bearer',
user,
}
// Supabase auth token key format: sb-{project}-auth-token
localStorage.setItem('sb-placeholder-auth-token', JSON.stringify(mockSession))
},
{ user: mockUser, accessToken: mockAccessToken }
)
}

// Extend fixture
export const test = base.extend<{
authenticatedPage: Page
}>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await use(page)
},
})

export { expect }

2. Use authenticatedPage in Tests

// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'

test.describe('Dashboard', () => {
test('should display welcome message', async ({ authenticatedPage }) => {
// Direct access to protected page, no login needed
await authenticatedPage.goto('/dashboard')

await expect(authenticatedPage.locator('h1')).toContainText('Welcome')
})

test('should show agent list', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')

const agents = authenticatedPage.locator('[data-testid="agent-card"]')
await expect(agents.first()).toBeVisible()
})
})

3. Comparison: Unauthenticated vs Authenticated

// Unauthenticated test (redirects to login)
test('redirects to login when not authenticated', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForTimeout(500)

expect(page.url()).toContain('/login')
})

// Authenticated test (direct dashboard access)
test('allows access when authenticated', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await authenticatedPage.waitForTimeout(500)

expect(authenticatedPage.url()).not.toContain('/login')
})

Core Principles

addInitScript vs Setting After Page Load

// ❌ Wrong: Set after page load (may have already redirected)
await page.goto('/dashboard')
await page.evaluate(() => {
localStorage.setItem('auth-token', '...')
})
// Auth guard already detected unauthenticated and redirected

// ✅ Correct: Inject before page load
await page.addInitScript(() => {
localStorage.setItem('auth-token', '...')
})
await page.goto('/dashboard') // Auth guard detects token on load

addInitScript executes:

  1. Before page DOM parsing
  2. Before React/Vue framework initialization
  3. Before auth guard checks

Fixture Lifecycle

test('dashboard', async ({ authenticatedPage }) => {})

base.extend<{ authenticatedPage }>()

authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page) // 1. Setup auth
await use(page) // 2. Run test
} // 3. Auto cleanup

Complete Configuration

// playwright.config.ts
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // Serial execution to avoid state pollution
workers: 1,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

Extension: Combined with API Mock

export const test = base.extend<{
authenticatedPage: Page
}>({
authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page)
await setupMockApi(page) // Also mock API
await use(page)
},
})

Key Principles

  1. Fixture over beforeEach — Automatic reuse, cleaner code
  2. addInitScript avoids race conditions — Inject token before auth guard checks
  3. Isolate test data — Don't mix mock users/tokens with production
  4. Serial execution prevents pollutionworkers: 1 or fullyParallel: false

Interested in similar solutions? Contact us

Bypass Supabase Auth for Playwright E2E Testing Without Login

· 4 min read

Encountered this issue while building an AI Agent SaaS platform for a client. Here's the root cause and solution.

TL;DR

E2E tests shouldn't depend on real OAuth login flows. By detecting localStorage test markers in the useAuth hook, you can inject mock auth state directly and skip Supabase initialization. Also change the Zustand store's loading default to false to prevent AuthGuard from showing an infinite spinner.

Problem

When testing a React SPA with Playwright, pages are protected by AuthGuard and require Supabase authentication. After the test starts, the page shows a loading spinner indefinitely and never reaches the business logic.

// AuthGuard component - tests get stuck here
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, loading } = useAuth()

if (loading) {
return <Spinner /> // Forever showing spinner
}

if (!isAuthenticated) {
return <Navigate to="/login" />
}

return <>{children}</>
}

Test code tries to simulate login, but Supabase Auth SDK's internal state can't be controlled by simple API mocking.

Root Cause

1. Supabase Auth Initializes Asynchronously

The useAuth hook calls supabase.auth.getSession() in useEffect, which is async. In test environments, network requests may fail or timeout, leaving state stuck at loading: true.

2. Zustand Store Default Value Problem

// authStore.ts - problematic code
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: true, // 👈 Default is true
// ...
}),
{ name: 'auth-storage' }
)
)

At test startup: loading: true + async init failure = forever loading.

3. OAuth Flow Can't Be Automated

Even if APIs can be mocked, OAuth redirect flows require real browser interaction that E2E tests can't reliably simulate.

Solution

Step 1: Add Test Mode Detection in useAuth Hook

// hooks/useAuth.ts
export function useAuth() {
const { user, token, loading, setUser, setToken, setLoading } = useAuthStore()

useEffect(() => {
const initAuth = async () => {
// 👇 Check test mode first
const testAuthUser = localStorage.getItem('test-auth-user')
const testAuthToken = localStorage.getItem('test-auth-token')

if (testAuthUser && testAuthToken) {
try {
const userData = JSON.parse(testAuthUser) as User
setUser(userData)
setToken(testAuthToken)
setLoading(false)
console.log('[useAuth] Using test mode auth')
return // 👈 Return early, skip Supabase init
} catch (e) {
console.error('Failed to parse test auth user:', e)
}
}

// 👇 Normal mode: proceed with Supabase Auth
try {
const { data: { session } } = await supabase.auth.getSession()
if (session) {
setUser(session.user as User)
setToken(session.access_token)
}
} catch (error) {
console.error('Auth init failed:', error)
} finally {
setLoading(false)
}
}

initAuth()

// 👇 Skip auth state listener in test mode
if (localStorage.getItem('test-auth-user')) {
return
}

const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
// ... normal auth state handling
}
)

return () => subscription.unsubscribe()
}, [])
}

Step 2: Modify Zustand Store Default Value

// stores/authStore.ts
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: false, // 👈 Change to false, let useAuth hook control state
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
setLoading: (loading) => set({ loading }),
logout: () => set({ user: null, token: null, loading: false }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, token: state.token }),
}
)
)

Step 3: Inject Test Auth in Playwright Fixture

// e2e/fixtures.ts
import { test as base } from '@playwright/test'

export const mockUser = {
id: 'test-user-id',
email: 'test@example.com',
created_at: '2024-01-01T00:00:00Z',
}

export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
// Visit page first to set localStorage origin
await page.goto('/login')

// 👇 Inject test auth state into localStorage
await page.evaluate(
({ user, token }) => {
localStorage.setItem('test-auth-user', JSON.stringify(user))
localStorage.setItem('test-auth-token', token)
},
{ user: mockUser, token: 'mock-access-token' }
)

// Navigate to protected page, useAuth will detect test mode
await page.goto('/dashboard')

await use(page)
},
})

Step 4: Use in Tests

// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'

test('dashboard shows user agents', async ({ authenticatedPage }) => {
// authenticatedPage is already authenticated, no login needed
await expect(authenticatedPage.getByText('Test Agent')).toBeVisible()
})

Complete Code Structure

agent-frontend/
├── e2e/
│ ├── fixtures.ts # Playwright fixture + mock data
│ ├── dashboard.spec.ts # Test cases
│ └── ...
├── src/
│ ├── hooks/
│ │ └── useAuth.ts # Test mode detection
│ └── stores/
│ └── authStore.ts # loading: false default
└── playwright.config.ts

Key Takeaways

  1. Use special prefix for test mode keys: test-auth-* won't appear in production
  2. Check before initialize: Check localStorage first, then proceed with Supabase Auth
  3. Skip auth listener: No need to listen for auth state changes in test mode
  4. Change loading default to false: Let the hook explicitly control loading state

Interested in similar solutions? Contact us

Fix React List Key Duplication Causing DOM Errors

· 2 min read

Encountered this issue while building an AI Agent chat interface. Here's the root cause and solution.

TL;DR

Date.now() millisecond timestamps can duplicate within the same millisecond. When used as React list keys, this causes DOM errors. Fix by adding a random suffix or using crypto.randomUUID().

Problem

When rapidly sending messages in a chat interface, the console shows:

Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

Messages disappear or render incorrectly.

Root Cause

Original code used Date.now() for message IDs:

// ❌ Problematic code
const id = `msg-${Date.now()}-user`

Date.now() returns millisecond timestamps (e.g., 1742345678001). The problem:

  1. Same millisecond = same value — JavaScript's event loop executes synchronous code much faster than 1ms
  2. Rapid operations trigger multiple calls — Fast message sending, SSE streaming creating multiple messages simultaneously
  3. Duplicate keys break reconciliation — React treats same-key elements as identical, causing DOM operation errors

Example: User sends two messages within 1ms, both get key msg-1742345678001-user.

Solution

// ✅ Fixed
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
  • Math.random().toString(36) generates base-36 random string
  • .slice(2, 9) extracts 7 characters for sufficient uniqueness
  • Timestamp + random string combination has extremely low collision probability

Option 2: Use crypto.randomUUID()

// ✅ More robust (requires modern browser or Node 15.6+)
const id = crypto.randomUUID() // e.g., "550e8400-e29b-41d4-a716-446655440000"
  • Cryptographically secure unique identifier
  • Collision-free guarantee
  • Compatibility: Chrome 92+, Firefox 95+, Safari 15.4+

Option 3: Counter + Timestamp

let counter = 0
const id = `msg-${Date.now()}-${counter++}-user`
  • Simple and reliable
  • Requires maintaining counter state

Complete Example

// Message creation in Zustand store
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}

export const useChatStore = create<ChatState>((set) => ({
messages: [],

addUserMessage: (content: string) => {
// ✅ Timestamp + random suffix
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
const message: ChatMessage = {
id,
role: 'user',
content,
timestamp: Date.now(),
}
set((state) => ({
messages: [...state.messages, message],
}))
return id
},
}))

Key Principles

  1. Keys must be unique and stable — Same element's key cannot duplicate among siblings
  2. Avoid using index as key — Causes issues when list order changes
  3. Avoid timestamp-only keys — Millisecond precision insufficient; microsecond (performance.now()) also unreliable

Interested in similar solutions? Contact us

Implementing Data Caching in Zustand Store

· 3 min read

Encountered this issue while building an AI Agent platform. Here's the root cause and solution.

TL;DR

Add lastFetchTime field and TTL constant to Zustand store. Check cache expiration before requesting. Implement effective data caching in ~10 lines of code, avoiding duplicate requests across pages.

Problem

MCP tools list is used by multiple pages (Agent Settings, Tools Marketplace, Chat tool selector). Each page entry triggers an API request:

GET /api/mcp-tools  → 200  (AgentSettingsPage)
GET /api/mcp-tools → 200 (McpToolsPage)
GET /api/mcp-tools → 200 (ChatPage tool selector)

Tools list rarely changes (admin configures manually), but frequent requests waste bandwidth and slow page loads.

Root Cause

Components call API directly without caching:

// ❌ No caching: requests on every mount
function McpToolsPage() {
const [tools, setTools] = useState([])

useEffect(() => {
mcpToolsApi.list().then(setTools)
}, [])

return <ToolList tools={tools} />
}

Problems:

  1. Each page requests independently — No global state sharing
  2. Repeated requests in short time — Triggered when user navigates between pages
  3. No refresh control — Re-fetches even when data unchanged

Solution

Zustand Store + TTL Cache

// src/stores/mcpToolsStore.ts
import { create } from 'zustand'
import { mcpToolsApi, type McpTool } from '@/services/api'

const CACHE_TTL = 10 * 60 * 1000 // 10 minutes

interface McpToolsState {
tools: McpTool[]
lastFetchTime: number | null
loading: boolean
error: string | null

fetchTools: (force?: boolean) => Promise<void>
clearError: () => void
}

export const useMcpToolsStore = create<McpToolsState>((set, get) => ({
tools: [],
lastFetchTime: null,
loading: false,
error: null,

fetchTools: async (force = false) => {
const { tools, lastFetchTime } = get()

// Has cache, not expired, not forced → skip
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // Cache hit
}
}

set({ loading: true, error: null })
try {
const data = await mcpToolsApi.list()
set({ tools: data, lastFetchTime: Date.now(), loading: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tools'
set({ error: message, loading: false })
}
},

clearError: () => set({ error: null }),
}))

Component Usage

// ✅ Using store cache
function McpToolsPage() {
const { tools, loading, fetchTools } = useMcpToolsStore()

useEffect(() => {
fetchTools() // Auto-checks cache
}, [fetchTools])

if (loading) return <Spinner />
return <ToolList tools={tools} />
}

// Force refresh
function RefreshButton() {
const { fetchTools } = useMcpToolsStore()
return <button onClick={() => fetchTools(true)}>Refresh</button>
}

Core Logic Explained

// Cache check logic
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // Cache valid, skip request
}
}
ConditionMeaning
tools.lengthHas data (empty array not valid cache)
lastFetchTimeRecorded last request time
!forceNot forced refresh
Date.now() - lastFetchTime < CACHE_TTLNot expired

Use Cases

ScenarioSuitableReason
Tool lists, config dictionaries✅ YesLow change frequency, shared across pages
User permissions, roles✅ YesRarely changes within session
Real-time data (messages, notifications)❌ NoNeeds latest state
Paginated lists❌ NoLarge data volume, complex caching strategy

Extension: Fine-grained Cache Control

interface CacheOptions {
ttl: number // Expiration time
staleWhileRevalidate: boolean // Return stale data while refreshing
}

// Background refresh after expiration, return cached data first
if (tools.length && lastFetchTime) {
const age = Date.now() - lastFetchTime
if (age < CACHE_TTL) {
return // Cache fresh
}
if (options.staleWhileRevalidate && age < CACHE_TTL * 2) {
// Cache stale but acceptable, background refresh
mcpToolsApi.list().then(data => set({ tools: data, lastFetchTime: Date.now() }))
return
}
}

Key Principles

  1. Set reasonable TTL — Based on data change frequency
  2. Provide force refresh — Users can manually get latest data
  3. Show loading on first fetch — Empty data shouldn't skip request

Interested in similar solutions? Contact us