voxel51/fiftyone-skills

fiftyone-app-playwright

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 Fif…

First seen Jul 9, 2026

Installation

$ npx skills add voxel51/fiftyone-skills --skill fiftyone-app-playwright

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from voxel51/fiftyone-skills · top by installs.

npx skills add voxel51/fiftyone-skills

Browse all from voxel51/fiftyone-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 39
License LICENSE
Default branch main
Open issues 4
Status Active

Skill metadata

Parsed from SKILL.md frontmatter.

CompatibilityRequires the Playwright MCP server and a Python environment with `fiftyone` installed (drives a live `fo.launch_app(...)` session).

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 12,165 B
  • docs SUMMARY.md 666 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 5 installs

SKILL.md

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 browsernavigate or location.reload() after an operator that calls ctx.ops.reloaddataset()

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/launchapp.py](scripts/launchapp.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()      # refresh this process's view of MongoDB
        session.refresh()   # push refresh event over existing WebSocket
    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)

// 1. Open palette
await page.keyboard.press('`');
// 2. Search (use the React-controlled-input pattern below)
const sb = document.querySelector('input[placeholder="Search operations by name..."]');
setter.call(sb, 'reload the dataset');
sb.dispatchEvent(new Event('input', { bubbles: true }));
// 3. browser_click the "Reload the dataset" result

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/launchapp.py --source <dataset> --clone <clone-name> --port 5151 > /tmp/foapp.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">. browserselectoption 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 }));
// Or for chips / drag handles: mousedown → mouseup → click

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;  // 60px top margin
      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:

  1. Expand "sample tags" via the caret (sidebar-field-arrow-enabled-tags) — NOT the checkbox (that toggles the whole group).
  2. Click the filter input (selector-sidebar-search-tags, placeholder "+ filter by sample tag") to open its autocomplete.
  3. 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 reloaddataset built-in (rule 1). reloadsamples 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: browserresize(width=2560, height=1440) before browsernavigate — default 1440×900 is coarse on Retina.

10. Cleanup (run between attempts)

# Kill stale launcher (match the script you launched)
pgrep -f launch_app.py | xargs -r kill; sleep 1
# Drop the non-persistent clone (no-op if already gone). $CLONE = your clone name.
python -c "import sys, fiftyone as fo; fo.dataset_exists(sys.argv[1]) and fo.delete_dataset(sys.argv[1])" "$CLONE"
# Remove ONLY recent orphan output files this run produced — NEVER a broad name glob alone.
# $OUTPUT_GLOB  = a pattern unique to YOUR outputs (e.g. "*_processed_*").
# $PREVIEW_FILE = any sidecar preview the operator wrote (e.g. ".fo_preview.jpg").
find "$MEDIA_DIR" -name "$OUTPUT_GLOB" -type f -mmin -30 -delete
find "$MEDIA_DIR" -name "$PREVIEW_FILE" -mmin -30 -delete
# Playwright snapshot scratch files
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

  • Launcher uses remote=True + trigger-file watcher (or session.wait() if rule 1 is acceptable)
  • Clone is non-persistent, deterministic name, pre-delete guard
  • Deterministic saved views / tags created by the launcher, not at demo time
  • Viewport ≥ 2560×1440 before first navigate
  • Never browser_navigate / location.reload() after an operator Execute
  • Post-Execute refresh via touch <trigger-file> OR reload_dataset via backtick palette
  • data-cy selectors first; leaf-text match as fallback
  • MUI dropdowns: real click to open, JS click on [role="option"] to select
  • React inputs: prototype setter + input event
  • Dialog scrolling: walk to scrollable ancestor, set scrollTop
  • Cleanup script scoped by -mmin / prefix, never bare name globs
  • Health check (curl + ps) between phases