| name | fiftyone-app-playwright |
| description | Use when driving the FiftyOne App via the Playwright MCP — plugin/operator verification, demo or screencast recording, or any end-to-end UI automation against `fo.launch_app(...)`. Covers the critical "do not navigate after reload_dataset" crash, launcher patterns (remote=True, trigger-file IPC), MUI-specific input/dropdown gotchas, sidebar tag filtering, `data-cy` selectors, dialog scrolling, session refresh strategies, and cleanup. Trigger on mentions of "Playwright + FiftyOne", "automate FiftyOne App", "operator demo", "browser_navigate crash", "reload_dataset", or any FiftyOne session that silently dies after a UI action.
|
| compatibility | Requires the Playwright MCP server and a Python environment with `fiftyone` installed (drives a live `fo.launch_app(...)` session). |
FiftyOne + Playwright Automation
Session notes for driving the FiftyOne App via the Playwright MCP. The App is a React/MUI SPA backed by a Python session over a WebSocket — most pitfalls come from that lifecycle.
1. The #1 Rule: Never browser_navigate or location.reload() after an operator that calls ctx.ops.reload_dataset()
Symptom. The FiftyOne server dies silently (curl localhost:5151 → HTTP 000; ps shows no PID). Dataset on disk is fine — the crash is session-layer. nohup/disown do NOT prevent it; the trigger is inside FiftyOne's own loop.
Cause. The navigate closes the active WebSocket while the remote session is mid-reload; session.wait() exits.
Fixes — ranked by cost.
A. session.refresh() via trigger-file IPC (best)
Neither session.refresh() nor dataset.reload() closes the WebSocket. They require a live handle, so replace session.wait() with a watcher loop. The bundled scripts/launch_app.py implements exactly this — clone a source dataset, launch remote=True, then poll a trigger file and reload() + refresh() whenever it appears:
nohup python scripts/launch_app.py \
--source quickstart --clone verify_clone --port 5151 \
> /tmp/fo_app.log 2>&1 &
The crux of the loop (see the script for the full version, including a guard that refuses to overwrite a persistent dataset sharing the clone name):
while True:
if os.path.exists(TRIGGER):
os.remove(TRIGGER)
clone.reload()
session.refresh()
time.sleep(0.5)
Automation side: touch /tmp/fo_refresh.trigger (the script's default --trigger path). Refresh lands in ~0.5 s, no UI round-trip. Extend watch() in the script for other side effects (mutate samples, create views, etc.). More robust IPC variants: Unix socket / named pipe, Jupyter kernel, or python -i with the session kept in a background shell.
B. "Reload the dataset" built-in operator via the backtick palette (no launcher changes)
await page.keyboard.press('`');
const sb = document.querySelector('input[placeholder="Search operations by name..."]');
setter.call(sb, 'reload the dataset');
sb.dispatchEvent(new Event('input', { bubbles: true }));
Slower (~2 s) but zero Python-side plumbing. Note: Reload samples from the dataset is a different, weaker operator — it does NOT refresh the sidebar tag index.
Why not a sibling-process dataset.reload()?
python -c "fo.load_dataset('clone').reload()" only refreshes that process's copy. The launcher's session and the browser WebSocket are untouched. You still need session.refresh() on the launcher — back to option A.
2. Launcher patterns
- Always
remote=True. Prevents a duplicate OS-browser tab on every navigate; launch Playwright and connect to http://localhost:5151 separately.
- Non-persistent clones with a pre-delete guard (the bundled launcher does this). They do NOT auto-delete on ungraceful crash — they linger in MongoDB until the next run's guard removes them.
- Run detached:
nohup python scripts/launch_app.py --source <dataset> --clone <clone-name> --port 5151 > /tmp/fo_app.log 2>&1 &. (nohup doesn't prevent the rule-1 crash; it just insulates from shell signal noise.)
- Health check between phases:
curl -s -o /dev/null -w "%{http_code}" localhost:5151 + ps -p $PID — detect silent crashes early.
- Always
remote=True: Prevents a duplicate OS-browser tab on every navigate; drive the App through the Playwright MCP browser at http://localhost:5151.
- If no window opens by itself tell the user where to watch: After the health check passes, report the App URL so the user can open it in their own browser as a passive viewer (watch, don't click, while automation is running). Whether the Playwright MCP browser itself is visible is fixed at MCP-server startup (
@playwright/mcp is headed unless started with --headless, default configuration is for 'headed' mode) — the skill works either way; don't attempt to change it mid-session.
3. MUI / React gotchas
React-controlled inputs
input.value = "foo" does not update React state. The UI shows it briefly then reverts; dynamic=True forms won't re-evaluate. Use the native prototype setter:
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
setter.call(inputEl, newValue);
inputEl.dispatchEvent(new Event('input', { bubbles: true }));
Applies to every text / number / textbox field in operator forms.
MUI dropdowns (combobox, not <select>)
FiftyOne uses <div role="combobox">. browser_select_option fails with "Element is not a <select>". Options only exist in the DOM while the listbox is open.
1. browser_click on the combobox (ref from a FRESH snapshot)
2. Wait ~1s for the listbox to mount
3. browser_evaluate:
Array.from(document.querySelectorAll('[role="option"]'))
.find(o => o.textContent.includes('Target label'))
.click()
Use real browser_click (not a synthesized .click()) to OPEN the popover — MUI's state machine doesn't always accept synthesized events for that.
Synthesized clicks
Most elements respond to .click(). For SVG icons, MUI chip close buttons, some option rows, dispatch a real MouseEvent:
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
Scrolling inside MUI dialogs
The dialog's outer container doesn't scroll — an inner div does, and scrollIntoView() on children is a no-op. Walk up to the scrollable ancestor and set scrollTop:
() => {
const target = document.querySelector('img[src*="/media?filepath"]');
let el = target.parentElement;
while (el) {
const s = getComputedStyle(el);
if ((s.overflowY === 'auto' || s.overflowY === 'scroll')
&& el.scrollHeight > el.clientHeight) {
const t = target.getBoundingClientRect();
const e = el.getBoundingClientRect();
el.scrollTop += t.top - e.top - 60;
return 'scrolled';
}
el = el.parentElement;
}
}
4. Finding elements
Prefer data-cy attributes
FiftyOne ships extensive data-cy hooks for its own Cypress tests — stable across versions.
data-cy | Element |
|---|
sidebar-field-container-tags | "sample tags" sidebar row |
sidebar-field-arrow-enabled-tags | Expand caret on that row |
checkbox-tags | Group visibility toggle (NOT a filter) |
categorical-filter-tags | Expanded filter area |
selector-sidebar-search-tags | "+ filter by sample tag" input |
flashlight-section | Main grid renderer |
looker | Each sample card in the grid |
Grep the installed FiftyOne app bundle for more — locate it with python -c "import os, fiftyone; print(os.path.join(os.path.dirname(fiftyone.__file__), 'app'))".
Text-content matching — filter to leaves
Ancestor <div>s contain every descendant string. Filter:
Array.from(document.querySelectorAll('*'))
.find(el => el.textContent.trim() === 'reviewed' && el.children.length === 0)
5. Sidebar tag filtering (non-obvious UX)
To filter the grid by a sample-tag value:
- Expand "sample tags" via the caret (
sidebar-field-arrow-enabled-tags) — NOT the checkbox (that toggles the whole group).
- Click the filter input (
selector-sidebar-search-tags, placeholder "+ filter by sample tag") to open its autocomplete.
- Click the desired tag row. It becomes a chip; grid filters; click the chip again to clear.
Tag rows are a dynamic autocomplete — not pre-rendered checkboxes.
6. Stale UI state after an operator mutation
After an Execute that adds samples/tags: the grid count lags and the sidebar tag index is stale. Fix with the reload_dataset built-in (rule 1). reload_samples is NOT enough — it doesn't refresh the tag index.
7. Modal viewer navigation
- Click the "Click to expand" ref on a card (real
browser_click, not a synthesized click on the canvas — that tends not to open the modal).
ArrowRight / ArrowLeft cycle the view; Escape closes. URL gets ?id=<sample_id>.
8. Pacing
- 1–2 s wait after any dialog closes (state-sync debounce).
- ~300–500 ms after input on
dynamic=True forms before checking for Warnings/Notices.
- 1–2 s between
ArrowRight presses for recording-grade pacing.
9. Recording tips
- Split long demos at crash-prone boundaries. Record pre-Execute and post-Done clips separately; splice in editor — cheaper than fighting session lifecycle.
- Use the same operator params across takes so clips stitch. Unseeded RNG varies per-sample but overall style stays consistent at matched intensities.
- Pre-configure deterministic state (clone, saved views, defaults) in the launcher so on-camera time is all creative action.
- Viewport:
browser_resize(width=2560, height=1440) before browser_navigate — default 1440×900 is coarse on Retina.
10. Cleanup (run between attempts)
pgrep -f launch_app.py | xargs -r kill; sleep 1
python -c "import sys, fiftyone as fo; fo.dataset_exists(sys.argv[1]) and fo.delete_dataset(sys.argv[1])" "$CLONE"
find "$MEDIA_DIR" -name "$OUTPUT_GLOB" -type f -mmin -30 -delete
find "$MEDIA_DIR" -name "$PREVIEW_FILE" -mmin -30 -delete
rm -f ./*-snap.md ./snap-*.md ./target-*.md ./sv-*.md 2>/dev/null
Cleanup safety: a bare find -name "$OUTPUT_GLOB" will match files from unrelated sessions you shouldn't touch. Always filter by -mmin or a session-specific prefix.
Pre-flight checklist