跳到主要内容

修复 FSE Group 块 layout 属性覆盖自定义 CSS 的问题

· 阅读需 3 分钟

TL;DR

WordPress FSE Group 块的 layout 属性会自动生成 is-layout-* CSS 类,这些类的样式优先级高于普通自定义 CSS,导致尺寸设置失效。解决方案:1) 块注释中使用 "layout":{"type":"default"} 避免生成额外布局类;2) CSS 中使用 !important 强制覆盖;3) 关键:添加 padding: 0 !important 清除 Group 块默认内边距。

问题现象

Timeline 组件的年份圆点应显示为 80px 正圆,实际却呈现为椭圆:

<!-- 块注释中的尺寸设置 -->
<!-- wp:group {"style":{"dimensions":{"width":"80px","height":"80px"}},"layout":{"type":"flex",...}} -->
/* 自定义 CSS */
.cclee-timeline-dot {
width: 80px;
height: 80px;
border-radius: 50%;
}

无论调整 CSS 还是块属性,圆点始终被拉伸变形。

根因

WordPress FSE 的 Group 块会根据 layout 属性自动添加布局相关的 CSS 类:

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

这些 is-layout-* 类来自 WordPress 核心样式表,其样式规则会覆盖自定义 CSS。同时,Group 块存在默认 padding,会撑大元素导致尺寸计算偏差。

关键问题点:

  1. layout: {"type": "flex"} 生成 is-layout-flex 类,子元素受 flexbox 拉伸影响
  2. 块注释中的 style.dimensions 转为 inline style,但被布局类样式覆盖
  3. Group 块默认 padding 增加了元素实际尺寸

解决方案

1. 修改块注释,使用 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%">

移除 style.dimensions 和复杂的 flex layout,改用 "layout":{"type":"default"}

2. CSS 强制覆盖 + 清除默认 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; /* 关键:清除默认 padding */
}

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

3. 使用 :has() 控制父容器

防止父级 Column 被 flexbox 拉伸:

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

关键发现

padding: 0 !important 是最终解决方案。Group 块的默认 padding 会撑大元素,即使设置了 width/height,实际渲染尺寸仍会超出预期。


对类似需求感兴趣?联系合作

修复 WordPress FSE 主题 Footer 文字不可见的 WCAG 对比度问题

· 阅读需 3 分钟

在为客户开发 WordPress FSE 企业主题时,发现 Footer 区块在多个 Style Variation 下文字几乎不可见。本文记录从 WCAG 对比度诊断到引入语义色、处理全局样式覆盖的完整修复过程。

TL;DR

问题:FSE 主题的 contrast 颜色 token 语义混乱,浅色主题中 contrast ≈ 浅灰 ≈ base(白色),导致 Footer 对比度仅 1.05:1。

解法

  1. 引入 surface 语义色,专用于深色区块背景
  2. 删除 wp_global_styles 中的覆盖样式
  3. 所有 Style Variations 同步添加 surface 定义

结果:对比度从 1.05:1 提升至 15.8:1(WCAG AAA 级)。

问题现象

Footer 区块使用 backgroundColor="contrast" + textColor="base"

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

在默认主题下,Footer 文字几乎不可见:

组合前景色背景色对比度WCAG
Footer 文字#ffffff (base)#f8fafc (contrast)1.05:1❌ 失败
Footer 链接#f59e0b (accent)#f8fafc (contrast)1.78:1❌ 失败

WCAG AA 标准要求普通文字对比度 ≥ 4.5:1,当前状态远不达标。

根因分析

1. contrast 语义混乱

contrast 的设计意图是"与 base 形成对比的背景色",但在不同主题模式下语义矛盾:

Variationbasecontrast期望 vs 实际
默认(浅色)#ffffff#f8fafc 浅灰期望深色,实际浅色
Tech(深色)#0f0f1a 深黑#1e1e2e 深紫期望浅色,实际深色

Footer Pattern 假设 contrast 是深色背景,但 5/6 的 Style Variations 中它是浅色。

2. 颜色语义缺乏明确用途定义

原设计系统只有 contrast 一个"对比色",没有区分:

  • 浅色对比区块(CTA Banner 等强调区域)
  • 深色对比区块(Footer、暗色 Hero 等)

解决方案

Step 1:引入 surface 语义色

theme.json 中新增 surface token,专用于深色区块背景:

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

Step 2:更新所有 Style Variations

每个 variation 定义自己的 surface 色(通常等于 primary):

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

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

// styles/tech.json(深色主题)
{ "slug": "surface", "color": "#1e1e2e", "name": "Surface" }
<!-- wp:group {"backgroundColor":"surface","textColor":"base"} -->
<div class="has-base-color has-surface-background-color">
Footer 内容
</div>

Step 4:删除全局样式覆盖

修改 theme.json 后颜色仍不生效?检查全局样式:

# 检查是否存在全局样式
docker exec wp_cli wp post list --post_type=wp_global_styles --fields=ID,post_title --allow-root

# 删除全局样式
docker exec wp_cli wp post delete <ID> --force --allow-root
docker exec wp_cli wp cache flush --allow-root

原因wp_global_styles 中的 color.palette完全覆盖(非合并)theme.json 的调色板。

修复结果

Variationsurface + base 对比度WCAG 级别
默认15.8:1✅ AAA
Commerce13.1:1✅ AAA
Industrial12.6:1✅ AAA
Professional9.9:1✅ AAA
Nature10.8:1✅ AAA
Tech11.5:1✅ AAA

颜色语义总结

Token用途
primary品牌主色(Logo、主按钮)
secondary次要元素
accent行动召唤(CTA、链接)
base页面主背景
contrast浅色对比区块背景
surface深色区块背景(Footer、暗色 CTA) ← 新增

对类似需求感兴趣?联系合作

解决 WordPress FSE Pattern 块验证失败的 5 种原因

· 阅读需 5 分钟

在为客户开发 WordPress FSE 主题时,频繁遇到 Block Pattern 验证失败问题。本文总结 5 种常见原因与解决方案。

TL;DR

块验证失败通常是以下原因之一:颜色 slug 未定义JSON 重复 keyStyle Variation 调色板覆盖HTML 属性与块注释不一致全局样式覆盖 theme.json。逐一排查即可解决。

问题现象

编辑器中 Pattern 显示红色警告:

Block contains unexpected or invalid content

尝试恢复块内容后,可能暂时正常,但刷新后问题复现。


原因一:颜色 slug 未定义

根因

Pattern 块属性引用了 theme.json 中不存在的颜色 slug:

<!-- 错误:neutral-text 不存在 -->
<!-- wp:paragraph {"textColor":"neutral-text"} -->
<p class="has-neutral-text-color">...</p>

解决方案

  1. 打开 theme.json,检查 settings.color.palette 定义的所有颜色
  2. 将 Pattern 中的无效 slug 替换为有效值
# 批量替换示例
cd patterns/
sed -i 's/"neutral-text"/"neutral-500"/g' *.php
sed -i 's/has-neutral-text-color/has-neutral-500-color/g' *.php

有效 slug 参考: primary, secondary, accent, base, contrast, neutral-50 ~ neutral-900


原因二:JSON 重复 key

根因

块注释 JSON 中同一层级出现重复 key(常见于复制粘贴):

// 错误:两个 style
{"style":{"typography":{...}},"style":{"spacing":{...}}}

JSON 规范不允许重复 key,解析器行为未定义。

解决方案

合并为单一 key:

// 正确
{"style":{"typography":{...},"spacing":{...}}}

排查命令:

# 搜索可能重复的 key
grep -n ',"style":{' patterns/*.php | head -20

原因三:Style Variation 覆盖调色板

根因

styles/*.json 中的 color.palette完全覆盖(非合并)父主题调色板。

当 Pattern 引用 neutral-500,但当前 Style Variation 未定义该颜色时,验证失败。

解决方案

每个 Style Variation 必须包含完整的 neutral 系列:

// 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" }
]
}
}
}

关键: neutral 系列色值必须与 theme.json 完全一致,只改变品牌色。


原因四:HTML 属性与块注释不一致

根因

这是最隐蔽的问题。WordPress save 函数对生成的 HTML 有严格要求。

问题 4.1:class 顺序错误

WordPress 生成 class 的固定顺序:

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

手写 HTML 时顺序错误会导致验证失败。

问题 4.2:背景色属性混用

背景色只能用 backgroundColor 属性,不可在 style.color.background 中声明:

<!-- 错误:混用导致 inline style 生成非法 CSS -->
<!-- wp:group {"style":{"color":{"background":"#f5f5f5"}}} -->

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

问题 4.3:border style 属性顺序

border-width 必须在 border-style 之前:

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

解决方案

最佳实践:从编辑器复制块代码,不要手写 HTML class 和 style。

  1. 在编辑器中配置好块
  2. 切换到代码编辑器视图
  3. 复制完整的块注释 + HTML
  4. 粘贴到 Pattern 文件

正确示例:

<!-- 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 -->

原因五:全局样式覆盖 theme.json

根因

Site Editor 保存的自定义样式存储在 wp_global_styles CPT 中,优先级高于 theme.json

修改 theme.json 后前端仍显示旧值,是因为全局样式覆盖了主题默认设置。

排查

# 检查是否存在全局样式
wp post list --post_type=wp_global_styles --fields=ID,post_title --allow-root

# 查看全局样式内容
wp post get <ID> --fields=post_content --allow-root

解决方案

# 删除全局样式
wp post delete <ID> --force --allow-root

# 清除缓存
wp cache flush --allow-root

预防: 开发阶段避免使用 Site Editor 自定义样式,所有配置通过 theme.json 管理。


排查流程总结

块验证失败

├─→ 检查颜色 slug 是否在 theme.json 中定义

├─→ 检查 JSON 是否有重复 key

├─→ 检查所有 Style Variation 是否包含完整调色板

├─→ 检查 HTML class/style 是否与块注释一致

└─→ 检查 wp_global_styles 是否覆盖了 theme.json

对类似需求感兴趣?联系合作

在 Docker WordPress 容器中安装 WP-CLI

· 阅读需 2 分钟

TL;DR

官方 WordPress Docker 镜像不含 WP-CLI。在 docker-compose.yml 中添加 command 配置,容器启动时自动下载安装 WP-CLI,无需手动进入容器操作。

问题现象

在 WordPress Docker 容器内执行 wp 命令:

docker exec -it wordpress_container wp --version

报错:

bash: wp: command not found

根因

WordPress 官方 Docker 镜像基于 php:apache 构建,设计目标是保持镜像精简。WP-CLI 是独立的命令行工具,需要额外安装,不在默认镜像中。

解决方案

docker-compose.yml 的 WordPress 服务中添加 command,容器启动时自动安装 WP-CLI:

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"

关键点:

  1. curl -sO - 静默下载 WP-CLI phar 包
  2. chmod +x - 添加执行权限
  3. mv ... /usr/local/bin/wp - 移动到 PATH 目录,使命令全局可用
  4. docker-entrypoint.sh apache2-foreground - 执行原镜像的默认入口点,启动 Apache

重启容器后验证:

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

对类似需求感兴趣?联系合作

用抽象类统一多搜索 API,错误返回而非抛异常

· 阅读需 5 分钟

在为客户构建 AI Agent 平台时遇到此问题:需要支持多个搜索提供商(Tavily、Serper、Brave、Bing),同时确保工具调用失败时不会中断 Agent 对话流程。

TL;DR

  1. 定义 SearchProvider 抽象基类 + SearchResult 数据模型,统一接口和输出格式
  2. 每个提供商继承基类,实现 search() 方法,内部做响应字段映射
  3. 关键设计:错误时返回包含错误信息的 SearchResult 对象,而非抛异常

问题现象

直接调用不同搜索 API 的问题:

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

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

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

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

问题

  1. 请求方式、认证头、响应结构各不相同
  2. 切换提供商需要改调用方代码
  3. raise Exception 会中断 AI Agent 的流式对话

根因

  1. 缺少抽象层:调用方直接依赖具体实现,违反依赖倒置原则
  2. 错误处理策略不统一:异常会沿调用栈向上传播,在流式场景下导致整个对话中断

对于 AI Agent 工具调用场景,Agent 需要根据错误信息决定是否重试、换用其他工具、或向用户说明情况——而不是直接崩溃。

解决方案

1. 定义抽象基类和数据模型

# 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. 实现具体提供商

Tavily(AI 优化搜索,支持 rate limit / quota 错误码):

# 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"
}
)

# 错误时返回 SearchResult,而非 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()

# 字段映射:Tavily 的 url -> 统一的 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()

# 字段映射:Serper 的 organic[].link -> 统一的 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)}")]

BraveBing 实现类似,区别在于请求方式和响应字段映射。

3. 调用方使用

# 使用时只需依赖抽象
async def execute_search(provider: SearchProvider, query: str) -> List[SearchResult]:
results = await provider.search(query)

# 检查是否有错误(通过 title 或 snippet 判断)
if results and not results[0].link:
error_msg = results[0].snippet
# Agent 可以根据错误信息决定下一步操作
return f"Search failed: {error_msg}"

return results


# 切换提供商只需换实例
provider = TavilySearch(api_key="xxx")
# provider = SerperSearch(api_key="xxx")
results = await execute_search(provider, "Python async best practices")

关键设计决策

决策原因
错误返回 SearchResult 而非 raiseAI Agent 对话是流式流程,异常会中断整个对话
用 Pydantic BaseModel 定义输出自动校验 + IDE 提示 + JSON 序列化
抽象类用 ABC 而非 Protocol需要共享 __init__ 逻辑(api_key 存储)
超时统一 15 秒搜索是用户体验关键路径,不能太慢

对类似需求感兴趣?联系合作

Playwright page.route() 实现 API 全量 Mock

· 阅读需 4 分钟

在构建 AI Agent 平台时遇到此问题,记录根因与解法。

TL;DR

使用 page.route() 拦截所有 API 请求,返回预定义的 mock 数据。测试不依赖真实后端,可以在任何环境稳定运行,且避免创建/删除数据等副作用。

问题现象

E2E 测试调用真实 API:

// ❌ 依赖真实后端
test('create agent', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/')

// 点击创建按钮
await authenticatedPage.click('button:has-text("Create")')

// 填写表单
await authenticatedPage.fill('input[name="name"]', 'Test Agent')
await authenticatedPage.click('button[type="submit"]')

// 等待 API 响应
await authenticatedPage.waitForTimeout(2000)

// 验证... 但如果后端挂了?如果数据库连接失败?
})

问题:

  1. 依赖后端状态 — 后端挂了测试就失败
  2. 数据副作用 — 每次运行创建真实数据
  3. 不可重复 — 数据变化导致断言失败
  4. CI 环境问题 — 需要启动完整后端服务

解决方案

1. 定义 Mock 数据

// 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. 设置 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') {
// 模拟创建
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. 在 Fixture 中使用

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

4. 测试中使用

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

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

// API 被自动 mock,返回 mockAgents
const agentCards = authenticatedPage.locator('[data-testid="agent-card"]')

// 断言基于已知的 mock 数据
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 被 mock,返回 201
await authenticatedPage.waitForTimeout(500)

// 验证 UI 更新
await expect(authenticatedPage.locator('text=New Agent')).toBeVisible()
})

核心技巧

URL 匹配模式

// 精确匹配
await page.route('**/api/agents', handler)

// 通配符匹配
await page.route('**/api/agents/**', handler)

// 正则匹配
await page.route(/\/api\/agents\/\d+/, handler)

读取请求体

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

// 根据请求内容返回不同响应
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 }) })
}
})

模拟错误场景

// 模拟网络错误
await page.route('**/api/agents', (route) => route.abort('failed'))

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

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

部分放行

// 只 mock 特定 API,其他放行
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() // 其他 API 走真实请求
}
})

Mock 数据管理

// 集中管理所有 mock 数据
// e2e/fixtures.ts
export const mockData = {
agents: [...],
skills: [...],
apiKeys: [...],
tasks: [...],
}

// 每个测试前重置
test.beforeEach(() => {
Object.assign(mockData, JSON.parse(JSON.stringify(originalMockData)))
})

关键原则

  1. Mock 所有外部依赖 — API、OAuth、第三方服务
  2. 模拟真实数据结构 — Mock 数据应与 API 契约一致
  3. 覆盖成功和失败场景 — 200/400/500 都要测试
  4. 隔离测试数据 — 每个测试用独立的 mock 数据副本

对类似需求感兴趣?联系合作

Playwright Custom Fixtures 实现免登录测试

· 阅读需 4 分钟

在构建 AI Agent 平台时遇到此问题,记录根因与解法。

TL;DR

使用 Playwright 的 test.extend() 创建自定义 fixture,通过 page.addInitScript() 在页面加载前注入 auth token 到 localStorage。测试用 authenticatedPage 替代 page,自动获得登录状态,无需每个测试重复登录。

问题现象

E2E 测试需要验证登录后才能访问的页面:

// ❌ 每个测试都要走登录流程
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')
// 终于可以开始测试了...
await expect(page.locator('h1')).toBeVisible()
})

问题:

  1. 每个测试重复登录 — 浪费时间,拖慢 CI
  2. 依赖真实认证服务 — Supabase Auth 不可用时测试失败
  3. 测试间状态污染 — 登录状态可能互相影响

解决方案

1. 创建 Custom Fixture

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

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

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

// 注入 auth token 到 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 格式: sb-{project}-auth-token
localStorage.setItem('sb-placeholder-auth-token', JSON.stringify(mockSession))
},
{ user: mockUser, accessToken: mockAccessToken }
)
}

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

export { expect }

2. 测试中使用 authenticatedPage

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

test.describe('Dashboard', () => {
test('should display welcome message', async ({ authenticatedPage }) => {
// 直接访问受保护页面,无需登录
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. 对比:未认证 vs 已认证

// 未认证测试(会重定向到登录页)
test('redirects to login when not authenticated', async ({ page }) => {
await page.goto('/dashboard')
await page.waitForTimeout(500)

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

// 已认证测试(直接访问 dashboard)
test('allows access when authenticated', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard')
await authenticatedPage.waitForTimeout(500)

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

核心原理

addInitScript vs 页面加载后设置

// ❌ 错误方式:页面加载后设置(可能已重定向)
await page.goto('/dashboard')
await page.evaluate(() => {
localStorage.setItem('auth-token', '...')
})
// 此时 auth guard 已经检测到未登录并重定向了

// ✅ 正确方式:页面加载前注入
await page.addInitScript(() => {
localStorage.setItem('auth-token', '...')
})
await page.goto('/dashboard') // 页面加载时 auth guard 检测到 token

addInitScript 在以下时机执行:

  1. 页面 DOM 开始解析前
  2. React/Vue 等框架初始化前
  3. Auth guard 检查前

Fixture 生命周期

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

base.extend<{ authenticatedPage }>()

authenticatedPage: async ({ page }, use) => {
await setupMockAuth(page) // 1. 设置 auth
await use(page) // 2. 执行测试
} // 3. 自动清理

完整配置

// playwright.config.ts
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // 串行执行避免状态污染
workers: 1,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})

扩展:结合 API Mock

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

关键原则

  1. Fixture 优于 beforeEach — 自动复用,代码更简洁
  2. addInitScript 避免竞态 — 在 auth guard 检查前注入 token
  3. 隔离测试数据 — Mock 用户和 token 不要与生产混淆
  4. 串行执行避免污染workers: 1fullyParallel: false

对类似需求感兴趣?联系合作

绕过 Supabase Auth 实现 Playwright E2E 测试免登录

· 阅读需 4 分钟

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR

E2E 测试不应该依赖真实的 OAuth 登录流程。通过在 useAuth hook 中检测 localStorage 的测试标记,直接注入 mock 认证状态,跳过 Supabase 初始化。同时将 Zustand store 的 loading 默认值改为 false,避免 AuthGuard 卡在无限 spinner。

问题现象

使用 Playwright 测试 React SPA 时,页面被 AuthGuard 组件保护,需要 Supabase 认证才能访问。测试启动后,页面一直显示 loading spinner,无法进入业务流程。

// AuthGuard 组件 - 测试时卡在这里
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, loading } = useAuth()

if (loading) {
return <Spinner /> // 永远显示 spinner
}

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

return <>{children}</>
}

测试代码尝试模拟登录,但 Supabase Auth SDK 内部状态无法通过简单的 API mock 控制。

根因

1. Supabase Auth 是异步初始化的

useAuth hook 在 useEffect 中调用 supabase.auth.getSession(),这是异步操作。测试环境下网络请求可能失败或超时,导致状态永远停留在 loading: true

2. Zustand Store 的默认值问题

// authStore.ts - 问题代码
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: true, // 👈 默认值是 true
// ...
}),
{ name: 'auth-storage' }
)
)

测试启动时,loading: true + 异步初始化失败 = 永远 loading。

3. OAuth 流程无法自动化

即使能 mock API,OAuth 的重定向流程需要真实浏览器交互,E2E 测试无法可靠模拟。

解决方案

步骤 1:在 useAuth hook 中添加测试模式检测

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

useEffect(() => {
const initAuth = async () => {
// 👇 优先检测测试模式
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 // 👈 直接返回,跳过 Supabase 初始化
} catch (e) {
console.error('Failed to parse test auth user:', e)
}
}

// 👇 正常模式:走 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()

// 👇 测试模式下跳过 auth state listener
if (localStorage.getItem('test-auth-user')) {
return
}

const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
// ... 正常的 auth state 处理
}
)

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

步骤 2:修改 Zustand Store 默认值

// stores/authStore.ts
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: false, // 👈 改为 false,让 useAuth hook 控制状态
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 }),
}
)
)

步骤 3:在 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) => {
// 先访问页面以设置 localStorage 的 origin
await page.goto('/login')

// 👇 注入测试认证状态到 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' }
)

// 导航到受保护页面,useAuth 会检测到测试模式
await page.goto('/dashboard')

await use(page)
},
})

步骤 4:在测试中使用

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

test('dashboard shows user agents', async ({ authenticatedPage }) => {
// authenticatedPage 已经通过认证,无需登录
await expect(authenticatedPage.getByText('Test Agent')).toBeVisible()
})

完整代码结构

agent-frontend/
├── e2e/
│ ├── fixtures.ts # Playwright fixture + mock 数据
│ ├── dashboard.spec.ts # 测试用例
│ └── ...
├── src/
│ ├── hooks/
│ │ └── useAuth.ts # 测试模式检测
│ └── stores/
│ └── authStore.ts # loading: false 默认值
└── playwright.config.ts

关键要点

  1. 测试模式 key 使用特殊前缀test-auth-* 不会在生产环境中出现
  2. 检测优先于初始化:先检查 localStorage,再走 Supabase Auth
  3. 跳过 auth listener:测试模式下不需要监听 auth state 变化
  4. loading 默认值改为 false:让 hook 显式控制 loading 状态

对类似需求感兴趣?联系合作

修复 React 列表 key 重复导致的 DOM 报错

· 阅读需 3 分钟

在开发 AI Agent 对话界面时遇到此问题,记录根因与解法。

TL;DR

Date.now() 毫秒级时间戳可能在同一毫秒内重复,作为 React 列表 key 会导致 DOM 报错。解决方案是添加随机后缀,或使用 crypto.randomUUID()

问题现象

聊天界面快速发送消息时,控制台报错:

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

消息列表渲染异常,部分消息消失或错位。

根因

原代码使用 Date.now() 生成消息 ID:

// ❌ 问题代码
const id = `msg-${Date.now()}-user`

Date.now() 返回毫秒级时间戳(如 1742345678001)。问题在于:

  1. 同一毫秒内多次调用返回相同值 — JavaScript 事件循环中,同步代码执行速度远快于 1ms
  2. 快速操作触发多次调用 — 用户快速发送消息、SSE 流式响应同时创建多条消息
  3. key 重复破坏 reconciliation — React 认为 key 相同的是同一元素,导致 DOM 操作错乱

示例:用户在 1ms 内发送两条消息,两条消息的 key 都是 msg-1742345678001-user

解决方案

方案一:添加随机后缀(推荐)

// ✅ 修复后
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
  • Math.random().toString(36) 生成 36 进制随机字符串
  • .slice(2, 9) 截取 7 位,提供足够的唯一性
  • 时间戳 + 随机串的组合,碰撞概率极低

方案二:使用 crypto.randomUUID()

// ✅ 更严格方案(需要现代浏览器或 Node 15.6+)
const id = crypto.randomUUID() // 如 "550e8400-e29b-41d4-a716-446655440000"
  • 密码学安全的唯一标识符
  • 无碰撞保证
  • 兼容性:Chrome 92+、Firefox 95+、Safari 15.4+

方案三:计数器 + 时间戳

let counter = 0
const id = `msg-${Date.now()}-${counter++}-user`
  • 简单可靠
  • 需要维护计数器状态

完整示例

// Zustand store 中的消息创建
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}

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

addUserMessage: (content: string) => {
// ✅ 时间戳 + 随机后缀
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
},
}))

关键原则

  1. key 必须唯一且稳定 — 同一元素在兄弟节点间 key 不能重复
  2. 避免使用 index 作为 key — 列表顺序变化时会出问题
  3. 避免仅用时间戳 — 毫秒级不够精确,微秒级(performance.now())也不可靠

对类似需求感兴趣?联系合作

在 Zustand Store 中实现数据缓存

· 阅读需 3 分钟

在构建 AI Agent 平台时遇到此问题,记录根因与解法。

TL;DR

在 Zustand store 中添加 lastFetchTime 字段和 TTL 常量,请求前检查缓存是否过期。10 行代码实现简单有效的数据缓存,避免多页面重复请求同一数据。

问题现象

MCP 工具列表被多个页面使用(Agent 设置页、工具市场页、聊天页),每次进入页面都触发 API 请求:

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

工具列表变化频率很低(管理员手动配置),频繁请求浪费带宽且影响页面加载速度。

根因

直接在组件中调用 API,没有缓存层:

// ❌ 无缓存:每次挂载都请求
function McpToolsPage() {
const [tools, setTools] = useState([])

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

return <ToolList tools={tools} />
}

问题:

  1. 每个页面独立请求 — 没有全局状态共享
  2. 短时间内重复请求 — 用户在页面间跳转时触发多次
  3. 无法控制刷新频率 — 即使数据未变化也重新获取

解决方案

Zustand Store + TTL 缓存

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

const CACHE_TTL = 10 * 60 * 1000 // 10 分钟

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()

// 有缓存且未过期且非强制 → 跳过
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // 缓存命中,直接返回
}
}

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 }),
}))

组件中使用

// ✅ 使用 store 缓存
function McpToolsPage() {
const { tools, loading, fetchTools } = useMcpToolsStore()

useEffect(() => {
fetchTools() // 自动检查缓存
}, [fetchTools])

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

// 强制刷新
function RefreshButton() {
const { fetchTools } = useMcpToolsStore()
return <button onClick={() => fetchTools(true)}>刷新</button>
}

核心逻辑解析

// 缓存检查逻辑
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // 缓存有效,跳过请求
}
}
条件说明
tools.length已有数据(空数组不算有效缓存)
lastFetchTime记录了上次请求时间
!force非强制刷新
Date.now() - lastFetchTime < CACHE_TTL未超过过期时间

适用场景

场景是否适合原因
工具列表、配置字典✅ 适合变化频率低,多页面共享
用户权限、角色✅ 适合会话内基本不变
实时数据(消息、通知)❌ 不适合需要最新状态
分页列表❌ 不适合数据量大,缓存策略复杂

扩展:更精细的缓存控制

interface CacheOptions {
ttl: number // 过期时间
staleWhileRevalidate: boolean // 过期后先返回旧数据再更新
}

// 过期后后台刷新,先返回缓存数据
if (tools.length && lastFetchTime) {
const age = Date.now() - lastFetchTime
if (age < CACHE_TTL) {
return // 缓存新鲜
}
if (options.staleWhileRevalidate && age < CACHE_TTL * 2) {
// 缓存过期但可接受,后台刷新
mcpToolsApi.list().then(data => set({ tools: data, lastFetchTime: Date.now() }))
return
}
}

关键原则

  1. 缓存时间要合理 — 根据数据变化频率设置 TTL
  2. 提供强制刷新入口 — 用户可手动刷新最新数据
  3. 首次加载要有 loading 状态 — 空数据时不应跳过请求

对类似需求感兴趣?联系合作