name: feishu-playwright-e2e description: "Playwright MCP guide for 飞书/Feishu Lark IM messenger E2E testing and 飞书聊天自动化 (feishu.cn, larkoffice.com). Read BEFORE operating 飞书聊天窗口 with Playwright: fill() and browser_fill_form fail with 'Element is not an input' on lark__editor contenteditable — use browser_type instead; @mention picker (飞书@提及用户/bot), Thread 回复面板, and sidebar navigation all require non-standard patterns you cannot guess. Covers: 飞书发消息, 读取消息历史, lark messenger 自动化, chatbot response testing via Feishu web UI." user-invocable: true allowed-tools: Bash, Read, Write, Edit, Grep, Glob, Task
Feishu Playwright E2E Testing Guide
This guide exists because Feishu's web client uses a custom rich-text editor framework (lark__editor) that breaks every standard Playwright assumption. The patterns below were discovered through 25+ failed attempts and refined into reliable workflows.
Prerequisites
Playwright MCP configured in
~/.claude.jsonwith Chrome Extension mode:{ "mcpServers": { "playwright": { "command": "npx", "args": ["@playwright/mcp@latest", "--extension", "--token", "<your-token>"] } } }Pass the token via
--tokenCLI argument, NOT environment variables —npxsubprocess does not reliably inherit env vars.Chrome has the Playwright MCP Extension installed with the same token.
Feishu Web is logged in within that Chrome instance.
Verify connection before any operation:
browser_snapshot() → should show current page, NOT "Playwright MCP extension"If you see the extension page, navigate explicitly:
browser_navigate("https://www.feishu.cn/messenger/")
The Cardinal Rules
These four rules prevent 80% of failures. Internalize them before doing anything:
Never use
fill()on Feishu's editor. The chat input isdiv.lark__editor--default— NOT an<input>,<textarea>, or[contenteditable]. Playwright'sfill()will always fail with "Element is not an input". Usekeyboard.type()orbrowser_typewithslowly: true(which callspressSequentiallyunder the hood).@mention is a UI interaction, not text. Typing
@KiraEvaproduces plain text that does not trigger Feishu's mention event. You must: type@slowly → wait for picker popup → click the target member.Snapshots will exceed token limits. Feishu Messenger produces 55-85KB accessibility trees. Always save to file and grep, or use
browser_run_codewith targeted JavaScript.Dismiss overlays with Escape, not clicks. Feishu modals have complex overlay structures where dropdown elements intercept pointer events.
page.keyboard.press('Escape')is universally reliable.
Navigation
Finding and entering a chat
Option A: Search (recommended for bot/user chats)
1. browser_click(ref=<search-box-ref>) # Click "搜索(⌘+K)"
2. browser_type(ref=<input-ref>, text="KiraEva", slowly=true)
# Playwright resolves this to: page.locator('#search_bar_editor').pressSequentially('KiraEva')
3. browser_click(ref=<result-ref>) # Click the search result
The search input's actual DOM ID is #search_bar_editor. It is NOT a standard <input> — it's part of Feishu's custom editor framework, same as the chat editor. Use slowly: true.
Option B: Click in sidebar list Save snapshot to file, grep for the chat name, click the ref. Works for recently active chats visible in the feed.
Option C: Pinned chats (快捷入口) Pinned chats appear as small avatar icons at the top of the sidebar (y ≈ 54, size ≈ 53×59px). They are often NOT in the snapshot's accessibility tree. Use JavaScript to find them:
// browser_run_code
async (page) => {
const allElements = document.querySelectorAll('*');
for (const el of allElements) {
if (el.textContent === 'OhMyClaw 开发群' && el.children.length === 0) {
el.click();
return 'clicked: ' + JSON.stringify(el.getBoundingClientRect());
}
}
return 'not found';
}
Target the leaf node (no children, exact text match) — this is the name label under the avatar.
Sending Messages
Simple text message (no @mention)
// browser_run_code — the most reliable approach
async (page) => {
const editor = page.locator('.lark__editor--default');
await editor.click();
await page.keyboard.type('your message here', { delay: 20 });
await page.keyboard.press('Enter');
return 'sent';
}
Alternatively, use MCP tools:
1. browser_click(ref=<editor-ref>)
2. browser_type(ref=<editor-ref>, text="message", slowly=true, submit=true)
The slowly: true is mandatory — without it, browser_type calls fill() which fails.
The chat input DOM structure
generic [ref=eXXX0]: # outer wrapper
generic [ref=eXXX2]: # editor (div.lark__editor--default)
generic [ref=eXXX5]: # placeholder text
"沟通时请保持\"公开可接受\""
generic [ref=eXXX7]: # toolbar row
button × 5 # formatting buttons
button [ref=eXXXX]: # send button (disabled until text entered)
generic: "Shift + Enter 换行" # hint below editor
The placeholder 沟通时请保持"公开可接受" is rendered as a child DOM element, not an HTML placeholder attribute. Refs change on every snapshot — never hardcode them.
@Mention Flow (5-Step Golden Path)
This is the most error-prone operation. Follow exactly:
Step 1: Click the editor
browser_click(ref=<placeholder-ref>)
# or: browser_click(ref=<editor-ref>)
Step 2: Type @ with slowly=true
browser_type(ref=<editor-ref>, text="@", slowly=true)
# This triggers Feishu's mention picker popup.
# Without slowly=true, the popup may not appear.
Step 3: Take snapshot, find target member
browser_snapshot() → save to file → grep for member name
# Picker structure:
# div.lark-mention-box — container
# div.mention-suggestion — each member item
# span.mention-suggestion_name — name text within item
Step 4: Click the target member
browser_click(ref=<member-ref>)
# This inserts a mention tag (<bdi> element) into the editor.
# Do NOT use getByText('KiraEva') — it matches 30+ elements in chat history.
# Use the ref from the picker, or:
# page.locator('div.mention-suggestion').filter({ hasText: 'KiraEva' }).click()
Step 5: Type message body on the mention tag ref, then send
browser_type(ref=<mention-bdi-ref>, text=" message body", slowly=true, submit=true)
# The ref changes after mention insertion — re-snapshot if needed.
# submit=true presses Enter to send.
Why plain text @mention doesn't work
Feishu's im.message.receive_v1 event only populates the mentions field when the message contains a real mention tag (a special DOM node created by the picker). Plain text @Name is just text — the bot receives the message but mentions is empty, so mention-based routing fails silently.
Thread Panel
Feishu has two distinct Thread scenarios with different DOM structures. Understanding which one you're in is critical.
Scenario A: Full-screen Thread (from 话题卡片)
Clicking a thread-forward card (thread-forward-card-root__clickable) in a private chat replaces the main chat area entirely. Only ONE contenteditable editor exists on the page. The editor container is .threadEditorContainer:
.threadEditorContainer → .lark__editor--topicDetail → .editor-kit-container[contenteditable]
Scenario B: Sidebar Thread (from "N 条回复")
Clicking .reply-meta-tips--pointer ("N 条回复") in a group chat opens a sidebar. TWO contenteditable editors exist simultaneously:
| Editor | Container | Purpose |
|---|---|---|
| Main chat | .chatEditorContainer (no .chatWindowEditor ancestor) |
Send to group |
| Thread reply | .chatWindowEditor → .chatThreadList → .chatEditorContainer |
Reply to thread |
Both editors share the same class (editor-kit-container) and placeholder text.
Locating the Thread editor
Method 1: CSS container selectors (most robust)
// Works for BOTH scenarios — tries threadEditorContainer first, then chatWindowEditor
const threadEditor = page.locator(
'.threadEditorContainer .editor-kit-container, .chatWindowEditor .editor-kit-container'
).first();
await threadEditor.click();
await page.keyboard.type('reply text', { delay: 20 });
Method 2: By index (Scenario B only)
// Main chat = nth(0), Thread = nth(1)
page.locator('[contenteditable="true"]').nth(1).click();
Method 3: By x-coordinate (Scenario B only)
async (page) => {
const editors = document.querySelectorAll('[contenteditable="true"]');
for (const ed of editors) {
const rect = ed.getBoundingClientRect();
if (rect.x > 1300 && rect.width > 100 && rect.height > 10) {
ed.focus();
return 'focused thread editor at x:' + Math.round(rect.x);
}
}
return 'thread editor not found';
}
Thread send button
The Thread editor's send button lives inside its container:
const sendBtn = page.locator(
'.threadEditorContainer .toolbar-item-submit-button button, ' +
'.chatWindowEditor .toolbar-item-submit-button button'
).first();
The outer div has class toolbar-item--disabled when no text is entered. After typing, this class disappears and the button becomes clickable. Alternatively, just press Enter to send.
"同时发送到群" checkbox
Thread editor has a .threadEditor__syncToChat checkbox to optionally send the reply to the main group chat as well.
Handling Large Snapshots
Feishu Messenger snapshots are 55-85KB. Three strategies:
Strategy 1: Save to file + grep (most common)
browser_snapshot(filename="/tmp/feishu-snapshot.md")
# Then:
grep "KiraEva\|OhMyClaw\|开发群" /tmp/feishu-snapshot.md
Strategy 2: Targeted JavaScript (fastest)
// browser_run_code — find specific elements without parsing the whole tree
async (page) => {
const editors = await page.locator('[contenteditable="true"]').all();
const results = [];
for (const ed of editors) {
const text = await ed.textContent().catch(() => '');
const box = await ed.boundingBox().catch(() => null);
results.push({ text: text?.substring(0, 100), box });
}
return JSON.stringify(results, null, 2);
}
Scrolling
Feishu has deeply nested scrollable containers. Scrolling the first matched element often scrolls the wrong container.
Find the right container:
async (page) => {
const scrollables = await page.evaluate(() => {
return Array.from(document.querySelectorAll('*')).filter(el => {
const style = getComputedStyle(el);
return el.scrollHeight > el.clientHeight &&
(style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll');
}).map(el => ({
className: el.className.substring(0, 60),
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight
}));
});
return JSON.stringify(scrollables, null, 2);
}
Then scroll the correct one by class name.
Clicking Ephemeral UI Elements
Feishu's "new message" banners and toast notifications often fail standard clicks due to transition animations or partial overlaps.
// Use CSS class + force:true
await page.locator('.messageTip__countTip--bottom').first().click({ force: true });
Prefer CSS class selectors over text selectors for ephemeral elements — text content like "1 条新消息" is fragile and locale-dependent.
Response Verification
After sending a test message to a bot:
Check backend logs first — the definitive completion signal:
grep "delivered -> replied" /tmp/bot-e2e.logThen verify in UI:
browser_snapshot() → save to file → grep for expected reply contentFor long-running tasks (Claude Code tool calls, etc.), the bot may take 2-3+ minutes. Don't blind-sleep — poll the logs:
tail -5 /tmp/bot-e2e.log # Check if still processingAfter restarting the bot, you MUST resend the test message. Messages consumed by the previous instance are gone — the new instance won't see them.
Quick Reference: Do vs Don't
| Operation | Don't | Do |
|---|---|---|
| Text input | browser_type(slowly=false) / fill() |
browser_type(slowly=true) / keyboard.type() |
| @mention | Type @Name as plain text |
Type @ slowly → picker → click member |
| Find editor | getByPlaceholder() / fill() |
locator('.lark__editor--default') / [contenteditable="true"] |
| Close modals | Click Cancel/Close button | keyboard.press('Escape') |
| Handle large snapshot | Parse in context | Save to file + grep, or browser_run_code |
| Click toast/banner | text=... selector |
CSS class selector + { force: true } |
| Navigate to chat | Cmd+K search + fill() | Search + slowly:true, or sidebar click, or JS brute-force |
| Scroll chat | scrollTop on first match |
Enumerate scrollable containers, target correct one |
| Find mention target | getByText('Name') |
locator('div.mention-suggestion').filter({ hasText: 'Name' }) |
| Thread editor | Guess which editor | CSS container (.threadEditorContainer, .chatWindowEditor) or .nth(1) or rect.x > 1300 |
| Press single key | browser_type |
browser_press_key(key="Enter") |
| Type text string | browser_press_key(key="full text") |
browser_type(slowly=true) / keyboard.type() |
Feishu-Specific CSS Selectors
| Element | Selector |
|---|---|
| Chat editor container | .lark__editor--default |
| Editor contenteditable element | .editor-kit-container |
| Main chat editor variant | .lark__editor--chat |
| Thread editor variant | .lark__editor--topicDetail |
| Send button (main chat) | .submit__button > .ud__button |
| Send button (thread) | .toolbar-item-submit-button button |
| Search input | #search_bar_editor |
| Mention picker container | div.lark-mention-box |
| Mention suggestion item | div.mention-suggestion |
| Mention name text | span.mention-suggestion_name |
| New message banner | .messageTip__countTip--bottom |
| Thread editor container (full-screen) | .threadEditorContainer |
| Thread editor container (sidebar) | .chatWindowEditor |
| Thread reply list | .chatThreadList |
| Editor wrapper | .chatEditorContainer |
| Thread sync checkbox | .threadEditor__syncToChat |
For the complete DOM structure map including element hierarchy, ref patterns, and the @mention picker layout, see references/feishu-dom-map.md (only read when you need deep DOM details beyond what's listed above).