Generated banner for Vscode Playwright
Generated logo for Vscode Playwright

Vscode Playwright

MicrosoftOfficialPublished by Microsoft

  • Skill
  • Workflow
  • Software development
  • MIT
  • Low risk
  • Has scripts

From the author

Capture VS Code screenshots and self-contained browser animation scenes through Playwright. Use for editor evidence, slide imagery, or silent WebM scene clips.
— the description by Microsoft

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured workflow for generating high-quality visual assets from VS Code and web-based animations. It supports both scripted, unattended execution for CI environments and interactive sessions via Playwright MCP tools. The workflow handles the lifecycle of the VS Code Web server, UI cleanup, viewport configuration, and screenshot validation to ensure output meets specific readability and aspect ratio requirements. Additionally, it includes a browser recorder for generating silent WebM animation clips from HTML scenes.

What it does

  • Automates VS Code Web server lifecycle management and environment cleanup.
  • Captures high-resolution screenshots of VS Code editor views and Copilot Chat.
  • Validates screenshot aspect ratios and rendered text sizes against target requirements.
  • Records self-contained HTML animations into silent WebM video clips.
  • Provides a scripted path for repeatable, headless capture in CI pipelines.
  • Supports interactive capture workflows using Playwright MCP browser tools.

When to use it

  • When creating consistent, high-quality editor screenshots for documentation or slide decks.
  • When automating the generation of visual evidence for software development workflows.
  • When producing silent browser-based animation clips for technical demonstrations.
  • When needing to ensure editor UI elements are consistently hidden or configured across multiple captures.

Advantages

  • Ensures visual consistency through automated UI cleanup and viewport sizing.
  • Supports both interactive and fully automated, headless capture modes.
  • Validates output quality by checking rendered font sizes and aspect ratios.
  • Provides a robust mechanism for handling VS Code Web settings and state.

Usage examples

“Capture a screenshot of the file 'main.py' in the current workspace for a 16:9 slide.”
The agent configures the viewport to 1920x1080, cleans the VS Code UI, opens the file, and saves a PNG screenshot.
“Record a 10-second animation of the browser scene at animation/demo/index.html.”
The agent uses the browser recorder to capture the scene as a silent WebM file at 24 fps.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/microsoft/vscode-playwright/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip vscode-playwright.zip -d .agents/skills

Documentation by Microsoft

VS Code and Browser Capture Skill

Captures VS Code editor views, code walkthroughs, and Copilot Chat examples, and records self-contained browser animation scenes as silent WebM clips.

Overview

This skill provides a complete workflow for capturing high-quality VS Code screenshots suitable for embedding in slide decks, documentation, and other visual media. It handles server lifecycle management, viewport configuration, UI cleanup, and screenshot validation.

Two paths share the same constraints. Prefer the scripted path for any repeatable or unattended capture, including CI. Use the interactive Playwright MCP path for exploratory or one-off captures.

Scripted Capture

scripts/capture_vscode.py runs a YAML capture plan headless: it starts serve-web, applies capture settings, opens each file in Monaco, measures the rendered font, and writes one screenshot per capture. It drives Playwright directly, so it needs no MCP server and runs on a CI runner.

uv sync
uv run playwright install --with-deps chromium
uv run python scripts/capture_vscode.py --plan capture-plan.yml --workspace /path/to/repo

A plan names each capture's id, a workspace-relative file, and an output path relative to the plan directory, or to --output-root when given. An id is one filename-safe token of letters, digits, ., _, or -. An output is a relative .png path with / separators and no .., and the script refuses to write outside the output root or through a symlink:

resolution: 1920x1080
min_font_pt: 18
font_size: 26
theme: Default Dark Modern
captures:
  - id: marketplace-catalog
    file: .github/plugin/marketplace.json
    output: frames/marketplace-catalog.png

The script exits 0 only when every capture was written and met min_font_pt, and prints one JSON result on its last stdout line with each capture's measured rendered_font_size_pt, zoom, and source_resolution. A failed capture also writes debug-<id>.png. It rejects markdown targets at plan validation and fails when the dark theme does not apply, so a clashing light capture cannot pass. When piping its output in CI, run the step under shell: bash so pipefail preserves the exit code.

Browser Animation Recording

scripts/record_browser_video.py records one self-contained HTML animation to a silent WebM clip from ready-scene frames. The scene must set data-animation-ready="true" on its body after local assets and animation state are ready and define window.startAnimation() to reset narrative motion and timers. Recording starts at that handshake, not at page navigation. The browser context is offline, service workers are blocked, WebSocket traffic is intercepted without forwarding, and popups are closed. A synthetic origin serves only files below the approved asset root; traversal and escaping symlinks are rejected. The default root is the scene directory; use --asset-root for a caller-approved shared asset directory containing the scene. Do not grant the workspace or home directory as an asset root.

uv run python scripts/record_browser_video.py \
  --scene animation/scene-001/index.html \
  --output clips/scene-001.webm \
  --duration 8 \
  --resolution 1920x1080

The recorder writes a silent clip. Pair it with the matching narration WAV in a demo-video segment; that assembler trims or loops the browser clip to the declared narration duration and supplies the final audio. Frames are sampled at 24 fps using virtual scene time, then encoded with FFmpeg. Startup and closing browser frames are excluded; duration is accurate to a video-frame boundary.

Prerequisites

Scripted screenshots require the VS Code or VS Code Insiders CLI (code or code-insiders). Interactive VS Code capture also requires genuine Playwright MCP browser tools and curl. Browser animation recording requires uv, the skill environment with Playwright 1.48 or newer, Chromium, and FFmpeg with libvpx-vp9; it does not require VS Code or MCP.

Tool names in this skill use an mcp_microsoft_pla_browser_* prefix. The actual prefix is derived from the MCP server's registration name in your host, so it may differ, for example mcp_playwright_browser_*. Match the prefix your host exposes.

Verified Constraints

These three constraints were confirmed against a running serve-web instance. Each one invalidates an approach that looks reasonable but does not work.

  • User settings live in the browser's IndexedDB, not on disk. Seeding --server-data-dir/data/User/settings.json has no effect on VS Code Web. Write them through Preferences: Open User Settings (JSON) by dispatching a paste event, because paste avoids both EditContext key handling and bracket auto-closing, then save and reload the page. The reload closes the settings editor, which Insiders opens as a modal that holds focus, and applies window-level settings such as workspace trust. A persistent browser context keeps the settings across the reload.

  • Markdown files open as a cross-origin preview webview rather than a Monaco editor. The webview DOM is unreachable from the page, so rendered text inside it cannot be inspected or measured. Capture a code or configuration file when the screenshot needs measurable editor text.

  • Confirm browser tooling by attempting a navigation rather than by matching tool names, because MCP tool prefixes vary by server registration. VS Code's built-in browser tools are a known case that loads the page but delivers neither keyboard nor mouse events to the VS Code Web workbench, so an attempt using them fails at the first command-palette step.

Architecture

The serve-web CLI is a Rust-based proxy ("server of servers") that downloads the VS Code Server release and proxies connections to the inner Node.js server. The outer CLI accepts a limited set of flags; --server-data-dir is the key flag that controls where server-side data such as the downloaded server build, extensions, and machine state is stored. It does not control user settings, which the browser holds in IndexedDB.

Quick Start

  1. Detect the VS Code CLI variant and start the web server.

  2. Navigate Playwright to the VS Code web instance.

  3. Clean up the UI (close panels, tabs, notifications).

  4. Open files and capture screenshots.

  5. Stop the server and clean up.

Workflow Steps

Step 1: Detect VS Code CLI Variant

Check the VSCODE_QUALITY environment variable first; if it contains insider, use code-insiders. Otherwise, test availability with command -v code-insiders and fall back to code. Store the result for reuse:

if [[ "${VSCODE_QUALITY:-}" == *insider* ]] || command -v code-insiders &>/dev/null; then
  VSCODE_CLI="code-insiders"
else
  VSCODE_CLI="code"
fi
Step 2: Start the VS Code Web Server

Create a temporary server data directory and launch serve-web. The --server-data-dir flag must receive a literal path, because shell variables from other terminal sessions are not available in background terminals:

VSCODE_SERVE_DIR=$(mktemp -d)
$VSCODE_CLI serve-web --port 8765 --without-connection-token \
  --accept-server-license-terms --server-data-dir "$VSCODE_SERVE_DIR"

Do not write a data/User/settings.json file under that directory. VS Code Web keeps user settings in the browser's IndexedDB, so a seeded settings file changes nothing. Set the theme, close restored editors, and hide UI through Command Palette commands after the workbench loads (Step 5), and raise rendered text size with document.body.style.zoom.

Do not try to edit the settings JSON editor by typing into a textarea selector either. VS Code Insiders uses the EditContext API instead of a textarea, so that element does not exist.

The serve-web command and mktemp must execute in the same terminal session so the $VSCODE_SERVE_DIR variable resolves. If using a background terminal (isBackground: true), inline the entire block — do not reference variables set in a different terminal.

Verify the server is ready before proceeding: curl -s -o /dev/null -w "%{http_code}" http://localhost:8765/ must return 200.

If the server log contains Ignoring option 'server-data-dir': Value must not be empty, the variable was empty — the server is using the default data directory instead of the ephemeral one. Kill the process and re-launch with the literal path.

Step 3: Navigate and Wait
  1. Navigate to the workspace: mcp_microsoft_pla_browser_navigate to http://localhost:8765/?folder=/path/to/workspace.

  2. Wait for VS Code to load: mcp_microsoft_pla_browser_wait_for with time: 5 to allow the editor UI to fully render.

Step 4: Resize Viewport

Resize the viewport to match the target placement ratio: mcp_microsoft_pla_browser_resize to a resolution whose aspect ratio matches the slide or document region where the screenshot will be inserted.

Calculate dimensions using width_px = 1200 and height_px = int(1200 / (target_width_inches / target_height_inches)). For example, a 5.5" x 4.2" placeholder produces a 1200 x 916 viewport.

When the screenshot fills a full 16:9 slide, size the viewport to 1920x1080 instead of deriving it from the 1200 px width rule. Use the width rule for every other placement, and do not use 1920x1080 there. Resize before cleanup so UI elements render at the target resolution.

Step 5: Clean Up the UI

Prepare the editor for clean screenshots using mcp_microsoft_pla_browser_run_code with the Command Palette pattern:

  1. Dismiss workspace trust dialog if present: take a mcp_microsoft_pla_browser_snapshot, look for a trust dialog, and click "Yes, I trust the authors" via mcp_microsoft_pla_browser_click if visible.

  2. Close all editors and tabs: Command Palette -> View: Close All Editors.

  3. Clear notifications: Command Palette -> Notifications: Clear All Notifications.

  4. Enable Do Not Disturb: Command Palette -> Notifications: Toggle Do Not Disturb Mode.

  5. Close Primary Side Bar: Command Palette -> View: Close Primary Side Bar.

  6. Close bottom panel: Take a mcp_microsoft_pla_browser_snapshot first. If the panel (Terminal, Problems, Output) is visible, run Command Palette -> View: Close Panel. Do not run this command blindly — it toggles visibility and opens a hidden panel.

  7. Close Secondary Side Bar: Take a mcp_microsoft_pla_browser_snapshot first. If the secondary side bar (Chat) is visible, run Command Palette -> View: Close Secondary Side Bar.

  8. Zoom in for readability: use mcp_microsoft_pla_browser_run_code with await page.evaluate(() => { document.body.style.zoom = '1.75'; }) for full-UI zoom. Treat no single factor as sufficient: raise the zoom and re-measure the rendered text size until it clears the caller's readability floor. At a 14 px base editor font, 18 pt is 24 CSS pixels, so roughly 1.75x is a starting point rather than a guarantee, because the base font size varies. Default font sizes become illegible (~7pt) when screenshots are shrunk to fit slide placeholders.

Step 6: Open Files and Capture

Open files via mcp_microsoft_pla_browser_run_code using the Command Palette pattern: Go to File command opens Quick Open, then type the filename and press Enter:

async (page) => {
  await page.keyboard.press('F1');
  await page.waitForTimeout(400);
  await page.keyboard.type('Go to File');
  await page.waitForTimeout(300);
  await page.keyboard.press('Enter');
  await page.waitForTimeout(500);
  await page.keyboard.type('doc-ops-update.prompt.md');
  await page.waitForTimeout(500);
  await page.keyboard.press('Enter');
  await page.waitForTimeout(1000);
  return 'File opened';
}

Set up the view: selectively open only the panels needed for this screenshot (split views, Copilot Chat, Explorer) via click-based navigation using mcp_microsoft_pla_browser_snapshot to find refs followed by mcp_microsoft_pla_browser_click. Keep the view focused on the subject.

Take the screenshot: mcp_microsoft_pla_browser_take_screenshot with type: "png" and a descriptive filename.

Validate the screenshot fits the target placement. Compare the captured image's aspect ratio against the target placeholder ratio. If they diverge by more than 5%, retake with corrected viewport dimensions. If a measured rendered text size falls below the caller's readability floor, retake with higher zoom. Iterate viewport and zoom adjustments until the screenshot matches the placement dimensions without distortion.

Repeat for additional screenshots. Close the current file's tab before opening the next (Command Palette -> View: Close All Editors).

Step 7: Copilot Chat Screenshots

For Copilot Chat screenshots: the Activity Bar is visible by default and settings seeding cannot change that, so leave it in place during cleanup. Open the Chat panel via Activity Bar click using mcp_microsoft_pla_browser_snapshot -> mcp_microsoft_pla_browser_click, type the prompt via mcp_microsoft_pla_browser_run_code with page.keyboard.type(), then wait for the response via mcp_microsoft_pla_browser_wait_for before capturing.

Step 8: Cleanup

Stop the VS Code web server and clean up the ephemeral environment:

pkill -f "serve-web.*8765" 2>/dev/null || true
rm -rf "$VSCODE_SERVE_DIR"

Also close the Playwright browser: mcp_microsoft_pla_browser_close.

Playwright MCP Command Palette Pattern

Individual MCP tool calls execute asynchronously, so the Command Palette closes between separate press_key, type, and press_key calls. All Command Palette operations must use mcp_microsoft_pla_browser_run_code to chain actions atomically in a single Playwright execution:

async (page) => {
  const runCommand = async (command) => {
    await page.keyboard.press('F1');
    await page.waitForTimeout(400);
    await page.keyboard.type(command);
    await page.waitForTimeout(300);
    await page.keyboard.press('Enter');
    await page.waitForTimeout(500);
  };

  await runCommand('View: Close All Editors');
  await runCommand('View: Close Primary Side Bar');
  // Chain additional commands as needed
  return 'Commands executed';
}

Never use separate mcp_microsoft_pla_browser_press_key -> mcp_microsoft_pla_browser_type -> mcp_microsoft_pla_browser_press_key calls for Command Palette operations — the palette loses focus between calls.

Troubleshooting

IssueCauseSolution
Ignoring option 'server-data-dir': Value must not be emptyShell variable resolved empty in background terminalInline the full command with the literal temp directory path or run mktemp and serve-web in the same session
Color Theme navigates to Marketplace themesNo theme selected yet, and settings seeding does not applyRun Command Palette -> Preferences: Color Theme after the workbench loads
Panel toggle opens hidden panelView: Toggle Panel Visibility is a toggleUse View: Close Panel only after confirming the panel is visible via snapshot
?file= parameter does not auto-open filesVS Code web only supports ?folder=Open files through Command Palette Go to File command after navigating
Text too small in screenshotsDefault ~14px font becomes ~7pt when shrunkRaise document.body.style.zoom and re-measure until it clears the readability floor
Screenshot aspect ratio distortionViewport ratio does not match placeholder ratioCalculate viewport from placeholder: width_px = 1200, height_px = int(1200 / (target_w / target_h))
UI clutter at slide-embedded sizesExplorer, minimap, tabs, toasts visibleClose all unnecessary UI elements before each capture
workbench.action.zoomIn does not workElectron-only commandUse editor.action.fontZoomIn or CSS zoom via page.evaluate()
Browser state restorationIndexedDB/localStorage restore previous filesClose editors via Command Palette after load; use a fresh browser context or incognito when available
Meta+P triggers browser actionKeyboard shortcuts intercepted by browserUse page.keyboard.press('F1') to open Command Palette
Screenshot saved to wrong directorytake_screenshot saves relative to Playwright working directoryCopy screenshots to the target directory after capture
Copilot Chat responses non-deterministicStreaming token-by-token outputUse mcp_microsoft_pla_browser_wait_for with expected text or time delay

Files

scripts/capture_vscode.py

#!/usr/bin/env python3
# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
"""Capture measured VS Code Web screenshots from a YAML capture plan.

Starts ``serve-web``, drives it headless with Playwright, opens each planned
file in the Monaco editor, raises zoom until the rendered font clears the
readability floor, and writes one screenshot per capture.

Playwright is driven directly rather than through an MCP server, so the same
script runs on a developer machine and on a CI runner.

Usage::

    python capture_vscode.py --plan capture-plan.yml --workspace /path/to/repo

Exit code:
    0 - every capture was written and met the readability floor
    1 - a capture failed, fell below the floor, or the harness errored

Prints a single JSON object on the last stdout line::

    {"ok": bool, "captures": [...], "errors": [str, ...]}
"""

from __future__ import annotations

import argparse
import contextlib
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path

import yaml

EXIT_SUCCESS = 0
EXIT_FAILURE = 1

DEFAULT_WIDTH = 1920
DEFAULT_HEIGHT = 1080
DEFAULT_MIN_FONT_PT = 18.0
DEFAULT_FONT_SIZE_PX = 26
DEFAULT_START_ZOOM = 1.0
MAX_ZOOM = 4.0
ZOOM_STEP = 0.25
CSS_PX_TO_PT = 0.75
SERVER_READY_TIMEOUT_S = 180
UI_SETTLE_MS = 700

# Markdown opens as a cross-origin preview webview whose font cannot be read.
UNMEASURABLE_SUFFIXES = frozenset({".md", ".markdown"})

# Capture IDs name debug screenshots, so they must be one filename-safe token.
CAPTURE_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")


class PlanError(ValueError):
    """Raised when the capture plan is missing or malformed."""


def _check_output_path(value: str, capture_id: str) -> None:
    """Require a relative ``.png`` path with no empty, ``.``, or ``..`` parts."""
    parts = value.split("/")
    if (
        "\\" in value
        or value.startswith("/")
        or re.match(r"^[A-Za-z]:", value)
        or any(part in ("", ".", "..") for part in parts)
        or not value.lower().endswith(".png")
    ):
        raise PlanError(
            f"capture '{capture_id}' 'output' must be a relative .png path "
            "inside the output root, using / separators"
        )


def contained_path(root: Path, relative: str) -> Path:
    """Return ``root / relative`` after proving it stays inside ``root``.

    Rejects a path that resolves outside the root, and any existing symlink
    between the root and the file, so a planted link cannot redirect a write.
    """
    base = root.resolve()
    candidate = base.joinpath(*relative.split("/"))
    current = base
    for part in candidate.relative_to(base).parts:
        current = current / part
        if current.is_symlink():
            raise PlanError(f"refusing to write through symlink {current}")
    if not candidate.resolve().is_relative_to(base):
        raise PlanError(f"{relative} resolves outside {base}")
    return candidate


def read_plan(path: Path) -> dict:
    """Load a capture plan YAML file into a dict."""
    try:
        data = yaml.safe_load(path.read_text(encoding="utf-8"))
    except (OSError, yaml.YAMLError) as exc:
        raise PlanError(f"cannot read capture plan {path}: {exc}") from exc
    if not isinstance(data, dict):
        raise PlanError("capture plan must be a YAML mapping")
    return data


def validate_plan(data: dict) -> tuple[dict, list[dict]]:
    """Validate and normalize a capture plan into settings and captures."""
    raw_captures = data.get("captures")
    if not isinstance(raw_captures, list) or not raw_captures:
        raise PlanError("capture plan requires a non-empty 'captures' list")

    width, height = _parse_resolution(data.get("resolution", "1920x1080"))
    min_font_pt = _as_positive_float(
        data.get("min_font_pt", DEFAULT_MIN_FONT_PT), "min_font_pt"
    )
    start_zoom = _as_positive_float(
        data.get("start_zoom", DEFAULT_START_ZOOM), "start_zoom"
    )
    font_size = data.get("font_size", DEFAULT_FONT_SIZE_PX)
    if not isinstance(font_size, int) or isinstance(font_size, bool) or font_size < 8:
        raise PlanError("'font_size' must be an integer of at least 8")

    settings = {
        "width": width,
        "height": height,
        "min_font_pt": min_font_pt,
        "start_zoom": start_zoom,
        "font_size": font_size,
        "theme": str(data.get("theme", "Default Dark Modern")),
    }

    seen_ids: set[str] = set()
    captures: list[dict] = []
    for index, item in enumerate(raw_captures):
        if not isinstance(item, dict):
            raise PlanError(f"capture {index} must be a mapping")
        capture_id = item.get("id")
        target = item.get("file")
        output = item.get("output")
        for field, value in (("id", capture_id), ("file", target), ("output", output)):
            if not isinstance(value, str) or not value.strip():
                raise PlanError(f"capture {index} requires a non-empty '{field}'")
        if not CAPTURE_ID_PATTERN.match(capture_id):
            raise PlanError(
                f"capture {index} 'id' must be letters, digits, '.', '_', or '-' "
                "and start with a letter or digit"
            )
        _check_output_path(output, capture_id)
        if capture_id in seen_ids:
            raise PlanError(f"duplicate capture id '{capture_id}'")
        seen_ids.add(capture_id)
        if Path(target).suffix.lower() in UNMEASURABLE_SUFFIXES:
            raise PlanError(
                f"capture '{capture_id}' targets markdown ({target}); markdown "
                "opens as a preview webview whose font size cannot be measured, "
                "so target a code or config file instead"
            )
        line = item.get("line", 1)
        if not isinstance(line, int) or line < 1:
            raise PlanError(f"capture '{capture_id}' 'line' must be a positive int")
        captures.append(
            {"id": capture_id, "file": target, "output": output, "line": line}
        )
    return settings, captures


def _parse_resolution(value) -> tuple[int, int]:
    if not isinstance(value, str) or "x" not in value:
        raise PlanError("resolution must look like WIDTHxHEIGHT")
    width_text, _, height_text = value.lower().partition("x")
    try:
        width, height = int(width_text), int(height_text)
    except ValueError as exc:
        raise PlanError("resolution must look like WIDTHxHEIGHT") from exc
    if width <= 0 or height <= 0:
        raise PlanError("resolution dimensions must be positive")
    return width, height


def _as_positive_float(value, name: str) -> float:
    try:
        number = float(value)
    except (TypeError, ValueError) as exc:
        raise PlanError(f"'{name}' must be a number") from exc
    if number <= 0:
        raise PlanError(f"'{name}' must be positive")
    return number


def rendered_font_pt(pre_zoom_px: float, post_zoom_px: float, zoom: float) -> float:
    """Return the rendered font size in points, counting zoom exactly once.

    When the host folds CSS zoom into the computed value, the post-zoom
    reading already includes it. Otherwise zoom is applied to the pre-zoom
    reading. Multiplying a folded reading by zoom again would inflate it.
    """
    effective_px = post_zoom_px if post_zoom_px != pre_zoom_px else pre_zoom_px * zoom
    return round(effective_px * CSS_PX_TO_PT, 2)


def _free_port() -> int:
    with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def _resolve_cli(explicit: str | None) -> str:
    candidates = [explicit] if explicit else ["code-insiders", "code"]
    for candidate in candidates:
        if candidate and shutil.which(candidate):
            return candidate
    raise RuntimeError(
        "VS Code CLI not found; install 'code' or 'code-insiders' or pass --cli"
    )


def _wait_for_server(url: str, timeout_s: int) -> None:
    deadline = time.monotonic() + timeout_s
    last_error: Exception | None = None
    while time.monotonic() < deadline:
        try:
            with urllib.request.urlopen(url, timeout=5) as response:
                if response.status == 200:
                    return
        except (urllib.error.URLError, OSError) as error:
            # The server refuses connections until it has started; keep polling.
            last_error = error
        time.sleep(2)
    raise RuntimeError(
        f"serve-web did not become ready within {timeout_s}s: {last_error}"
    )


@contextlib.contextmanager
def serve_web(cli: str, port: int, data_dir: Path):
    """Run ``serve-web`` for the duration of the block."""
    # serve-web answers 202 while downloading the inner server on first run.
    process = subprocess.Popen(
        [
            cli,
            "serve-web",
            "--port",
            str(port),
            "--without-connection-token",
            "--accept-server-license-terms",
            "--server-data-dir",
            str(data_dir),
        ],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    try:
        _wait_for_server(f"http://127.0.0.1:{port}/", SERVER_READY_TIMEOUT_S)
        yield
    finally:
        process.terminate()
        try:
            process.wait(timeout=10)
        except subprocess.TimeoutExpired:
            process.kill()


def _run_command(page, command: str) -> None:
    page.keyboard.press("F1")
    box = page.locator(".quick-input-box input")
    box.wait_for(state="visible", timeout=10_000)
    box.press_sequentially(command, delay=15)
    page.wait_for_timeout(UI_SETTLE_MS)
    page.keyboard.press("Enter")
    page.wait_for_timeout(UI_SETTLE_MS)


def _open_file(page, relative_path: str) -> None:
    _run_command(page, "Go to File...")
    box = page.locator(".quick-input-box input")
    box.wait_for(state="visible", timeout=10_000)
    box.press_sequentially(relative_path, delay=15)
    # Wait for the picker to filter to a match before accepting it.
    page.locator(".quick-input-list .monaco-list-row").first.wait_for(
        state="visible", timeout=15_000
    )
    page.wait_for_timeout(UI_SETTLE_MS)
    page.keyboard.press("Enter")
    page.locator(".part.editor .monaco-editor .view-line").first.wait_for(
        state="visible", timeout=20_000
    )


def capture_settings(theme: str, font_size: int) -> dict:
    """Return the VS Code user settings applied before capturing."""
    return {
        "workbench.colorTheme": theme,
        "window.autoDetectColorScheme": False,
        "security.workspace.trust.enabled": False,
        "workbench.activityBar.location": "hidden",
        "workbench.statusBar.visible": False,
        "workbench.startupEditor": "none",
        "workbench.editor.showTabs": "single",
        "breadcrumbs.enabled": False,
        "window.commandCenter": False,
        "editor.fontSize": font_size,
        "editor.lineHeight": 1.5,
        "editor.minimap.enabled": False,
        "editor.stickyScroll.enabled": False,
        "editor.renderLineHighlight": "none",
        "editor.guides.indentation": False,
        "editor.links": False,
        "telemetry.telemetryLevel": "off",
    }


def _apply_settings(page, settings: dict) -> None:
    # VS Code Web keeps settings in IndexedDB, so write them via the editor.
    # Paste bypasses EditContext key handling and bracket auto-closing.
    _run_command(page, "Preferences: Open User Settings (JSON)")
    # Scope to the editor part: chat renders hidden .monaco-editor widgets too.
    editor = page.locator(".part.editor .monaco-editor .view-lines").first
    editor.wait_for(state="visible", timeout=20_000)
    editor.click()
    page.keyboard.press("ControlOrMeta+a")
    page.keyboard.press("Delete")
    payload = json.dumps(settings, indent=2)
    page.evaluate(
        """(text) => {
            const data = new DataTransfer();
            data.setData('text/plain', text);
            document.activeElement.dispatchEvent(
                new ClipboardEvent('paste', {clipboardData: data, bubbles: true})
            );
        }""",
        payload,
    )
    page.wait_for_timeout(UI_SETTLE_MS)
    page.keyboard.press("ControlOrMeta+s")
    page.wait_for_timeout(2 * UI_SETTLE_MS)
    # Reload closes the modal settings editor and applies window-level settings
    # such as workspace trust; the persistent context keeps them in IndexedDB.
    page.reload(wait_until="domcontentloaded")
    page.locator(".monaco-workbench").wait_for(state="visible", timeout=60_000)
    page.wait_for_timeout(3_000)


def _assert_dark_theme(page) -> None:
    # Fail loudly: a light capture silently clashes with the dark deck.
    is_dark = page.evaluate(
        "() => document.querySelector('.monaco-workbench')"
        ".classList.contains('vs-dark')"
    )
    if not is_dark:
        raise RuntimeError(
            "dark theme did not apply; the workbench is still in a light theme"
        )


def _prepare_workbench(page, settings: dict) -> None:
    page.locator(".monaco-workbench").wait_for(state="visible", timeout=60_000)
    page.wait_for_timeout(3_000)
    _apply_settings(page, capture_settings(settings["theme"], settings["font_size"]))
    _assert_dark_theme(page)
    for command in (
        "View: Close All Editors",
        "Notifications: Clear All Notifications",
    ):
        with contextlib.suppress(Exception):
            _run_command(page, command)


def _hide_side_bars(page) -> None:
    # Toggle commands flip state, so only act on bars that are visible.
    for selector, command in (
        (".part.sidebar", "View: Close Primary Side Bar"),
        (".part.auxiliarybar", "View: Close Secondary Side Bar"),
        (".part.panel", "View: Close Panel"),
    ):
        with contextlib.suppress(Exception):
            if page.locator(selector).first.is_visible():
                _run_command(page, command)


def _read_font_px(page) -> float:
    return page.evaluate(
        """() => {
            const el = document.querySelector(
                '.part.editor .monaco-editor .view-line span'
            ) || document.querySelector('.part.editor .monaco-editor .view-line');
            return el ? parseFloat(getComputedStyle(el).fontSize) : 0;
        }"""
    )


def _set_zoom(page, zoom: float) -> None:
    page.evaluate("(z) => { document.body.style.zoom = String(z); }", zoom)
    page.wait_for_timeout(UI_SETTLE_MS)


def measure_and_fit(page, start_zoom: float, min_font_pt: float) -> dict:
    """Raise zoom until the measured font clears the floor, then report it."""
    _set_zoom(page, 1.0)
    pre_px = _read_font_px(page)
    if pre_px <= 0:
        raise RuntimeError("no Monaco .view-line found to measure")
    zoom = start_zoom
    while True:
        _set_zoom(page, zoom)
        post_px = _read_font_px(page)
        font_pt = rendered_font_pt(pre_px, post_px, zoom)
        if font_pt >= min_font_pt or zoom >= MAX_ZOOM:
            return {
                "zoom": zoom,
                "pre_zoom_px": pre_px,
                "post_zoom_px": post_px,
                "rendered_font_size_pt": font_pt,
            }
        zoom = round(zoom + ZOOM_STEP, 2)


def run_captures(
    plan_path: Path, workspace: Path, output_root: Path, cli: str | None
) -> dict:
    """Execute every capture in the plan and return a result summary."""
    settings, captures = validate_plan(read_plan(plan_path))
    from playwright.sync_api import sync_playwright

    port = _free_port()
    results: list[dict] = []
    errors: list[str] = []
    with tempfile.TemporaryDirectory(prefix="vscode-capture-") as tmp:
        tmp_path = Path(tmp)
        with serve_web(_resolve_cli(cli), port, tmp_path / "server"):
            with sync_playwright() as playwright:
                # Persistent context keeps VS Code's IndexedDB settings stable.
                context = playwright.chromium.launch_persistent_context(
                    str(tmp_path / "browser"),
                    headless=True,
                    viewport={"width": settings["width"], "height": settings["height"]},
                )
                try:
                    page = context.pages[0] if context.pages else context.new_page()
                    page.goto(
                        f"http://127.0.0.1:{port}/?folder={workspace.resolve()}",
                        wait_until="domcontentloaded",
                    )
                    _prepare_workbench(page, settings)
                    for capture in captures:
                        results.append(
                            _capture_one(page, capture, settings, output_root, errors)
                        )
                finally:
                    context.close()

    ok = not errors and all(r.get("meets_floor") for r in results)
    return {"ok": ok, "captures": results, "errors": errors}


def _capture_one(
    page, capture: dict, settings: dict, output_root: Path, errors: list[str]
) -> dict:
    result = {"capture_id": capture["id"], "monaco_target": capture["file"]}
    try:
        _set_zoom(page, 1.0)
        with contextlib.suppress(Exception):
            _run_command(page, "View: Close All Editors")
        _open_file(page, capture["file"])
        if capture["line"] > 1:
            _run_command(page, "Go to Line/Column...")
            page.keyboard.type(str(capture["line"]))
            page.keyboard.press("Enter")
        _hide_side_bars(page)
        measurement = measure_and_fit(
            page, settings["start_zoom"], settings["min_font_pt"]
        )
        output_path = contained_path(output_root, capture["output"])
        output_path.parent.mkdir(parents=True, exist_ok=True)
        page.screenshot(path=str(output_path))
        result.update(measurement)
        result.update(
            {
                "path": capture["output"],
                "source_resolution": f"{settings['width']}x{settings['height']}",
                "meets_floor": measurement["rendered_font_size_pt"]
                >= settings["min_font_pt"],
            }
        )
        if not result["meets_floor"]:
            errors.append(
                f"{capture['id']}: {measurement['rendered_font_size_pt']} pt is "
                f"below the {settings['min_font_pt']} pt floor at max zoom"
            )
    except Exception as exc:  # noqa: BLE001 - reported per capture, not raised
        result["meets_floor"] = False
        with contextlib.suppress(Exception):
            debug_path = contained_path(output_root, f"debug-{capture['id']}.png")
            debug_path.parent.mkdir(parents=True, exist_ok=True)
            page.screenshot(path=str(debug_path))
            result["debug_screenshot"] = str(debug_path)
        errors.append(f"{capture['id']}: {exc}")
    return result


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--plan", required=True, type=Path, help="Capture plan YAML")
    parser.add_argument(
        "--workspace", required=True, type=Path, help="Folder opened in VS Code"
    )
    parser.add_argument(
        "--output-root",
        type=Path,
        help="Base directory for capture outputs (default: the plan's directory)",
    )
    parser.add_argument("--cli", help="VS Code CLI to run (default: auto-detect)")
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    output_root = args.output_root or args.plan.parent
    try:
        summary = run_captures(args.plan, args.workspace, output_root, args.cli)
    except PlanError as exc:
        summary = {"ok": False, "captures": [], "errors": [f"plan: {exc}"]}
    except Exception as exc:  # noqa: BLE001 - surfaced as a JSON result
        summary = {"ok": False, "captures": [], "errors": [f"harness: {exc}"]}
    print(json.dumps(summary))
    return EXIT_SUCCESS if summary["ok"] else EXIT_FAILURE


if __name__ == "__main__":
    os.environ.setdefault("PYTHONUNBUFFERED", "1")
    sys.exit(main())

Requirements and compatibility

Written for: GitHub Copilot

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an MCP server, Needs network access, Needs Python

The author says: VS Code screenshots require its CLI. Scripted capture requires uv, Python 3.11+, and Chromium; animation encoding also requires FFmpeg. Interactive capture requires Playwright MCP tools and curl

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Microsoft
Publisher
Microsoft (Official)
Source
microsoft/hve-core · .github/skills/experimental/vscode-playwright · commit f425e94
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Microsoft.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download vscode-playwright.zip