feishu-playwright-e2e

star 5

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.

vincentor By vincentor schedule Updated 2/27/2026

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

  1. Playwright MCP configured in ~/.claude.json with Chrome Extension mode:

    {
      "mcpServers": {
        "playwright": {
          "command": "npx",
          "args": ["@playwright/mcp@latest", "--extension", "--token", "<your-token>"]
        }
      }
    }
    

    Pass the token via --token CLI argument, NOT environment variables — npx subprocess does not reliably inherit env vars.

  2. Chrome has the Playwright MCP Extension installed with the same token.

  3. Feishu Web is logged in within that Chrome instance.

  4. 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:

  1. Never use fill() on Feishu's editor. The chat input is div.lark__editor--default — NOT an <input>, <textarea>, or [contenteditable]. Playwright's fill() will always fail with "Element is not an input". Use keyboard.type() or browser_type with slowly: true (which calls pressSequentially under the hood).

  2. @mention is a UI interaction, not text. Typing @KiraEva produces plain text that does not trigger Feishu's mention event. You must: type @ slowly → wait for picker popup → click the target member.

  3. Snapshots will exceed token limits. Feishu Messenger produces 55-85KB accessibility trees. Always save to file and grep, or use browser_run_code with targeted JavaScript.

  4. 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:

  1. Check backend logs first — the definitive completion signal:

    grep "delivered -> replied" /tmp/bot-e2e.log
    
  2. Then verify in UI:

    browser_snapshot() → save to file → grep for expected reply content
    
  3. For 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 processing
    
  4. After 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).

Install via CLI
npx skills add https://github.com/vincentor/claude-code-plugins --skill feishu-playwright-e2e
Repository Details
star Stars 5
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator