Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@
"skills": [
"./brand-yml",
"./shiny/shiny-bslib",
"./shiny/shiny-bslib-theming"
"./shiny/shiny-bslib-theming",
"./shiny/shiny-reactivity",
"./shiny/shiny-testing",
"./shiny/shiny-for-agents"
]
},
{
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ Skills for Shiny app development in both R and Python.
- **[brand-yml](./brand-yml/)** - Create and apply brand.yml files for consistent styling across Shiny apps, with support for bslib (R) and ui.Theme (Python), including automatic brand discovery and theming functions for plots and tables
- **[shiny-bslib](./shiny/shiny-bslib/)** - Build modern Shiny dashboards using bslib with Bootstrap 5 layouts, cards, value boxes, navigation, theming, and modern inputs. Includes migration guide from legacy Shiny patterns
- **[shiny-bslib-theming](./shiny/shiny-bslib-theming/)** - Comprehensive theming for Shiny apps using bslib, covering bs_theme(), Bootswatch themes, custom colors, typography, Bootstrap Sass variables, custom Sass/CSS rules, dark mode, dynamic theming, and R plot theming
- **[shiny-reactivity](./shiny/shiny-reactivity/)** - Design and debug Shiny's reactive graph in R. Choose between reactive(), observe(), observeEvent(), and eventReactive(); manage state with reactiveValues; control execution with req(), bindEvent(), bindCache(), and debounce(); and debug with reactlog, tracebacks, and profvis
- **[shiny-testing](./shiny/shiny-testing/)** - Test Shiny apps in R across three layers: pure functions with testthat, reactive logic with shiny::testServer(), and end-to-end with shinytest2 (AppDriver, snapshots)
- **[shiny-for-agents](./shiny/shiny-for-agents/)** - Drive, inspect, and debug a running Shiny app (R and Python) with a browser tool (Playwright or agent-browser), reading server-side errors the browser can't see and authoring apps to be legible to agents

### Quarto

Expand Down
33 changes: 31 additions & 2 deletions shiny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,44 @@ Comprehensive theming for Shiny apps using bslib and Bootstrap 5. Use when custo
- [Bootswatch themes](https://bootswatch.com/)
- [thematic package](https://rstudio.github.io/thematic/)

### `shiny-reactivity`

Design and debug Shiny's reactive graph in R. Use when choosing between `reactive()`, `observe()`, `observeEvent()`, and `eventReactive()`; managing state with `reactiveValues`; controlling when things run with `req()`, `bindEvent()`, `bindCache()`, or `debounce()`; or debugging reactivity with reactlog, tracebacks, and profvis.

**Organization**: Single self-contained SKILL.md covering reactive-graph design patterns, common anti-patterns (the observer-writes-reactiveValues trap), and a debugging workflow.

**Resources**:
- [Mastering Shiny: Reactivity](https://mastering-shiny.org/reactivity-intro.html)
- [reactlog package](https://rstudio.github.io/reactlog/)

### `shiny-testing`

Test Shiny apps in R across three layers — pure functions with testthat, reactive logic with `shiny::testServer()`, and end-to-end with shinytest2 (`AppDriver`, snapshots). Use when writing or fixing tests for a Shiny app or module, testing reactives without a browser, setting up shinytest2, or deciding what to test where.

**Organization**: Single self-contained SKILL.md organized by the three testing layers, cheapest first, with guidance on what to test where.

**Resources**:
- [shinytest2 package](https://rstudio.github.io/shinytest2/)
- [Mastering Shiny: Testing](https://mastering-shiny.org/scaling-testing.html)

### `shiny-for-agents`

Drive, inspect, and debug a running Shiny app (R and Python) with a browser tool (Playwright or agent-browser). Use when launching a Shiny app for inspection, driving its UI without flaky sleeps, reading server-side errors the browser can't see, or authoring an app to be legible to agents.

**Organization**: SKILL.md explains Shiny's split-runtime model and agent-driving workflow, plus helper scripts:
- `scripts/launch_app_dev.R` - Launch a Shiny app in a dev-friendly mode for inspection
- `scripts/wait_for_shiny_idle.js` - Wait on `shiny:busy`/`shiny:idle` events instead of fixed sleeps

**Resources**:
- [Shiny JavaScript events](https://shiny.posit.co/r/articles/build/js-events/)

## Potential Skills

This category could include skills for:

- Shiny app architecture and best practices
- Reactive programming patterns
- UI/UX design for Shiny apps
- Performance optimization
- Testing Shiny applications
- Deployment strategies
- Module development
- Extension creation
Expand Down
116 changes: 116 additions & 0 deletions shiny/shiny-for-agents/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
name: shiny-for-agents

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we need to split this into two skills.... Maybe shiny-for-agents-py and shiny-for-agents-r?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Motivation: All the examples are R. Similarly, it'd be nice to have python examples. At that point, I'm happy to lean on Claude to keep the two docs in sync while allowing for different runtime languages.

description: Drive, inspect, and debug a running Shiny app (R and Python) with a browser tool (Playwright or agent-browser). Use when launching a Shiny app for inspection, driving its UI without flaky sleeps, reading server-side errors the browser can't see, or authoring an app to be legible to agents. Triggers on driving or screenshotting a Shiny app, the shiny:busy/idle/error JS events, and server errors invisible to the browser.
metadata:
author: Samuel Bharti (@samuelbharti)
version: "1.0"
license: MIT
---

# Shiny for Agents

Shiny is **split-runtime**: the UI (DOM, inputs/outputs) runs in the browser, but the reactive graph, all state, and the logic run in a separate R/Python **server process** over a websocket. Driving the browser (Playwright/agent-browser) shows you only the client half — the half that is *not* the source of truth.

**What you can see today, and what you can't:**

| Readable from the browser | NOT readable (lives in the server) |
|---|---|
| DOM, rendered output, input values (`Shiny.shinyapp.$inputValues`) | Current value of any reactive/expression/scoped variable |
| Busy/idle state, the `shiny:*` JS events | Why a reactive fired, the invalidation trace |
| Errors *if* devmode is on (see below) | Server errors/warnings (unless surfaced) |

So: drive and assert from the browser, but get errors from the **server process output**, and design the app so its logic is testable without a browser at all. The deepest introspection (reading reactive values, tracing invalidation) needs Shiny-side support that doesn't exist yet — see the last section.

## 0. Setup — install the best-practice tooling

Install the recommended packages first; the no-install/manual path is a **fallback, not the default**. Use the project's env manager (`renv`, `uv`/`venv`), not global installs; confirm before system/sudo-level steps.

- **R (install these):** `install.packages(c("shinytest2", "reactlog"))` — `shinytest2` is the official E2E tool (drives real Chrome via `chromote` with idle-waiting built in); `reactlog` is the reactive-graph debugger. See **`shiny-testing`** / **`shiny-reactivity`**.
- **Python (install these):** `uv add --dev pytest playwright pytest-playwright` then `uv run playwright install chromium`; use the `shiny.playwright` controllers for E2E.
- **Browser driver:** Playwright is JS/Python; **R has no Playwright** — drive R apps from `chromote` (which `shinytest2` already uses) or from a Playwright/agent-browser process pointed at the running app over HTTP. The `shiny-busy` idle signal (below) is identical either way.
- **Interactive driving:** prefer the **Playwright MCP or agent-browser** tool when present (no install). *Alternate* — raw Playwright (Python) / `chromote` (R) + the idle helper below.
- **Fallback only** (can't install / locked-down env): drive an already-running app and scrape server stdout for errors (see *Read errors the browser can't show*). On restricted Linux, the official `mcr.microsoft.com/playwright` image ships browsers + OS libs.

## 1. Authoring for legibility

Habits that make an app driveable and debuggable without instrumentation:

- **Stable, explicit IDs.** Hand-write every `inputId`/`outputId` (e.g. `id = "country_filter"`). Never auto-generate them — your selectors and assertions depend on them.
- **Consistent modules.** One `moduleServer(id, ...)` per module, every child ID wrapped in `ns()`, so the DOM id is predictable: `#<moduleId>-<inputId>`.
- **Logic in named reactives / pure functions, not inside `render*`** — so it's testable without a browser. See **`shiny-reactivity`** for reactive design and **`shiny-testing`** for the headless test layers.

## 2. Run for inspection

Launch once, fixed port, dev features on, randomness pinned. Use [scripts/launch_app_dev.R](scripts/launch_app_dev.R) or:

```r
options(shiny.port = 7654, shiny.host = "127.0.0.1", shiny.fullstacktrace = TRUE)
shiny::devmode(TRUE) # client-side error console + dev defaults
set.seed(1); Sys.setenv(TZ = "UTC")
shiny::runApp("path/to/app", launch.browser = FALSE)
```

- Run it in the **background and capture stdout/stderr** — that stream is your source of truth for server errors.
- `launch.browser = FALSE` — *your* browser attaches, at `http://127.0.0.1:7654`.
- Determinism: fixed seed/timezone/fixtures so assertions don't flap.

## 3. Drive the app — never `sleep`, wait on `shiny-busy`

> Act → wait for the server to go idle → assert. **Never** `waitForTimeout`/`sleep`.

This section is for **live interactive driving** (Playwright MCP / agent-browser against a running app). For anything you'd *commit as a test*, use `shinytest2` (R) or the `shiny.playwright` controllers (Python) — they encapsulate all of this; see **`shiny-testing`**. The helper below is the fallback for the no-wrapper case.

Shiny adds the **`shiny-busy` class to `<html>`** while the server works and removes it when idle — the same signal `shinytest2` waits on internally. Wait on that. Full helper (connect, idle-wait, error recorder): [scripts/wait_for_shiny_idle.js](scripts/wait_for_shiny_idle.js).

```js
// idle = no `shiny-busy` on <html>, stable for 250ms (absorbs the busy-not-yet-set race)
await page.waitForFunction((stableMs) => {
if (document.documentElement.classList.contains('shiny-busy')) {
window.__lastBusy = Date.now(); return false;
}
if (window.__lastBusy === undefined) return true;
return Date.now() - window.__lastBusy >= stableMs;
}, 250, { polling: 50, timeout: 30000 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works. But feels like a hack. I'd rather expose a true script from within py-shiny.

If the python scripts already uses playwright, we should leverage shiny.playwright assertions.

I guess this also means that we should drop agent-browser in favor of playwright (or chromote for R)

```

The act → settle → assert loop:

```js
await page.fill('#country_filter', 'Canada'); // ACT
await waitForShinyIdle(page); // SETTLE
const txt = await page.textContent('#summary'); // ASSERT
```

**Don't** gate idle on the `.recalculating` class globally — suspended/hidden outputs can keep it indefinitely (they don't render until shown), and some hide/`removeUI` sequences leave it stuck (rstudio/shiny#4114), so a global `.recalculating === 0` check can hang. Use it only scoped to one visible output: `page.waitForSelector('#plot:not(.recalculating)')`.

**Settling races (important).** `waitForShinyIdle` can return *before* a slow round-trip even starts — the stable window elapses against the pre-action quiet period — so a read taken right after may be stale. Two robust fixes (both in the helper):
- **Asserting a change?** Wait for the change itself, not a fixed settle: `assertOutputChanged(page, '#plot img', action, {attr:'src'})` snapshots, acts, and resolves once the output differs. This is the reliable "did it update?" oracle and immune to render nondeterminism.
- **Need to settle, then read?** Use `actAndSettle(page, action)` — it waits for the server to *become busy* (or an output to enter `.recalculating`) before waiting for idle, closing the race.

## 4. Read errors the browser can't show

The real traceback is in the **server process stdout/stderr** (`shiny.fullstacktrace = TRUE`) — read it there first. With `devmode(TRUE)` the client also shows the error; catch *which* output failed via the `shiny:error` event (`e.name`, `e.error`). By default client errors are sanitized to "An error has occurred"; the real text is server-side.

To go beyond observing — reproducing a server error or asserting on a reactive's value — reach for the headless test layers in **`shiny-testing`** (`testServer` for reactive logic, `shinytest2` for end-to-end), and **`shiny-reactivity`** for diagnosing *why* a reactive did or didn't fire.

## 5. Shiny for Python

The client JS is identical, so the idle-waiting approach (wait on `shiny-busy`) and the `shiny:*` events apply unchanged. Differences: launch with `shiny run --port 7654 --reload app.py`; server errors go to the `shiny run` process output; there is **no `testServer`** — write logic as pure Python functions and unit-test them; E2E uses `pytest` + `shiny.playwright` controllers (`from shiny.playwright import controller`), which wait on Shiny state for you.

## 6. The wall, and how to work with it

You **can** expose chosen reactive values for inspection by opting in: under `shiny.testmode`, `exportTestValues()` (R) registers internals that `shinytest2` reads via `app$get_value(export=)` (Shiny for Python is gaining an equivalent — posit-dev/py-shiny#2270). What there's still **no** API for is reading *arbitrary* reactive state without that opt-in, or getting a machine-readable invalidation trace (not even a `shiny:invalidated` JS event; `shiny:recalculating` lags invalidation). So the most reliable way to inspect server state today is to design for it: put logic in named reactives / pure functions and exercise it headlessly (see *Authoring for legibility* above and **`shiny-testing`**) rather than trying to read it through the browser.

## See also

- **`shiny-reactivity`** — design and debug the reactive graph (reactlog, tracebacks, "why didn't this update").
- **`shiny-testing`** — the headless test layers (`testServer`, `shinytest2`).
- **`shiny-bslib`** — building the UI.

## References

Fetch only for an edge case or to confirm a current API signature:

- [Shiny JS events](https://shiny.posit.co/r/articles/build/js-events/) — full `shiny:*` event list + properties.
- [Shiny for Python: end-to-end testing](https://shiny.posit.co/py/docs/end-to-end-testing.html) — `shiny.playwright` controllers + pytest fixtures.
- [Playwright](https://playwright.dev/docs/intro) · [Playwright MCP](https://github.com/microsoft/playwright-mcp) — driving APIs and the MCP server setup.
30 changes: 30 additions & 0 deletions shiny/shiny-for-agents/scripts/launch_app_dev.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env Rscript
# launch_app_dev.R — launch a Shiny app for deterministic agent inspection.
#
# Usage: Rscript launch_app_dev.R [path/to/app] [port]
# Defaults: app ".", port 7654. Run in the background and CAPTURE its
# stdout/stderr — that stream is the source of truth for server errors.
# Your browser attaches to http://127.0.0.1:<port>; no system browser opens.

if (!requireNamespace("shiny", quietly = TRUE)) {
stop("Package 'shiny' is required. Install with install.packages('shiny').", call. = FALSE)
}

args <- commandArgs(trailingOnly = TRUE)
app_dir <- if (length(args) >= 1) args[[1]] else "."
port <- if (length(args) >= 2) as.integer(args[[2]]) else 7654L

options(
shiny.port = port,
shiny.host = "127.0.0.1",
shiny.fullstacktrace = TRUE, # full server traceback on error
shiny.sanitize.errors = FALSE # show real error text in dev
)
shiny::devmode(TRUE) # client-side error console + dev defaults

# Determinism: pin anything the app might pull from the environment.
set.seed(1)
Sys.setenv(TZ = "UTC")

message(sprintf("Launching '%s' on http://127.0.0.1:%d", app_dir, port))
shiny::runApp(app_dir, launch.browser = FALSE)
Loading
Loading