Skip to content
Merged
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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ All notable changes to this project will be documented here.
## [Unreleased]

### Added
- Public `clipboard.reset_backend_cache()` helper. Replaces the previous
pattern of poking the module-private `_backend` global directly (which
the autouse fixtures in `tests/test_server.py` and
`tests/test_integration_x11.py` were already doing). Production code
has no reason to call this; it exists for tests that switch backends
or re-read `MCP_CLIPBOARD_BACKEND` mid-process. (#104)
- Direct unit tests for `parser._has_header_row` covering the
`len(rows) < 2` early-return path and the all-text-no-header path,
plus a positive case for text-headers-over-numeric-data. Previously
exercised only indirectly via `format_table` JSON output. (#104)
- Coverage for `clipboard_paste` `include_schema=True` when the data
rows are wider than the header row -- the padding loop at
`server.py:222-228` now has a regression test that asserts synthetic
`Col 3` / `Col 4` labels are emitted. (#104)
- Coverage for `MCP_CLIPBOARD_MAX_WRITE_BYTES` and
`MCP_CLIPBOARD_MAX_IMAGE_BYTES` env-var validation. Both vars are
parsed via `int(os.environ.get(...))` at module import; a non-integer
value raises `ValueError` before anything else runs. Tests exercise
each in a subprocess so the partial-import state stays out of the
in-process module cache. (#104)
- Headless X11 integration tests (`tests/test_integration_x11.py`) and a
matching CI `integration-x11` job that runs them against a real `xclip`
process under Xvfb. Five round-trip tests exercise plain text, unicode,
Expand Down
11 changes: 11 additions & 0 deletions src/mcp_clipboard/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,17 @@ def _get_backend() -> str:
return _backend


def reset_backend_cache() -> None:
"""Clear the cached backend so the next call to _get_backend() re-detects.

Intended for tests that need to switch backends or re-read
MCP_CLIPBOARD_BACKEND mid-process. Production code should not need
this -- the cache is a process-lifetime decision.
"""
global _backend
_backend = None


_READERS = {
"wayland": _wayland_read,
"x11": _x11_read,
Expand Down
6 changes: 3 additions & 3 deletions tests/test_integration_x11.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@

import pytest

import mcp_clipboard.clipboard as cb
from mcp_clipboard.clipboard import (
list_clipboard_formats,
read_clipboard,
read_clipboard_image,
reset_backend_cache,
write_clipboard,
write_clipboard_typed,
)
Expand All @@ -47,9 +47,9 @@
def _force_x11_backend(monkeypatch):
"""Force the X11 backend for every test in this module."""
monkeypatch.setenv("MCP_CLIPBOARD_BACKEND", "x11")
cb._backend = None
reset_backend_cache()
yield
cb._backend = None
reset_backend_cache()


@pytest.mark.asyncio
Expand Down
29 changes: 29 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re

from mcp_clipboard.parser import (
_has_header_row,
detect_content_type,
extract_html_text,
format_table,
Expand Down Expand Up @@ -461,6 +462,34 @@ def test_detect_code_python_def_still_detected():
assert detect_content_type("def add(a, b):") == "code"


# ---------------------------------------------------------------------------
# _has_header_row direct coverage
# ---------------------------------------------------------------------------


def test_has_header_row_single_row():
"""Single row triggers the early return; can't be a header without data."""
assert _has_header_row([["Name", "Age", "City"]]) is False


def test_has_header_row_empty():
"""Zero rows also takes the early-return path."""
assert _has_header_row([]) is False


def test_has_header_row_uniform_text_no_header():
"""When every row (header included) is text and data is also text, no
column type differs, so no header is detected."""
rows = [["Alice", "Bob"], ["Carol", "Dan"], ["Eve", "Frank"]]
assert _has_header_row(rows) is False


def test_has_header_row_text_header_over_numeric_data():
"""Classic header case: text labels over numeric data rows."""
rows = [["Count", "Total"], ["1", "100"], ["2", "200"], ["3", "300"]]
assert _has_header_row(rows) is True


# ---------------------------------------------------------------------------
# Schema inference
# ---------------------------------------------------------------------------
Expand Down
73 changes: 68 additions & 5 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from __future__ import annotations

import asyncio
import os
from unittest.mock import AsyncMock, patch

import pytest
Expand Down Expand Up @@ -167,6 +168,70 @@ async def test_paste_schema_not_appended_for_non_table():
assert "hello world" in result


def test_max_write_bytes_non_integer_raises_at_import():
"""A non-integer MCP_CLIPBOARD_MAX_WRITE_BYTES raises ValueError at module
load (server.py runs ``int(os.environ.get(...))`` at import time).

Run as a subprocess so the bad env var and partial-import state are
contained -- reloading mcp_clipboard.server in-process would break
every test that holds a reference to the module's exception classes.
"""
import subprocess
import sys

env = {**os.environ, "MCP_CLIPBOARD_MAX_WRITE_BYTES": "not-a-number"}
proc = subprocess.run(
[sys.executable, "-c", "import mcp_clipboard.server"],
env=env,
capture_output=True,
text=True,
)
assert proc.returncode != 0
assert "ValueError" in proc.stderr
assert "not-a-number" in proc.stderr


def test_max_image_bytes_non_integer_raises_at_import():
"""Same for MCP_CLIPBOARD_MAX_IMAGE_BYTES on the clipboard module."""
import subprocess
import sys

env = {**os.environ, "MCP_CLIPBOARD_MAX_IMAGE_BYTES": "ten-megs"}
proc = subprocess.run(
[sys.executable, "-c", "import mcp_clipboard.clipboard"],
env=env,
capture_output=True,
text=True,
)
assert proc.returncode != 0
assert "ValueError" in proc.stderr
assert "ten-megs" in proc.stderr


@pytest.mark.asyncio
async def test_paste_with_schema_pads_short_header_row():
"""When data rows are wider than the header row, the schema table
pads with synthetic Col N labels (server.py:222-228 padding loop)."""
# Header has 2 cells, data rows have 4. Padding should add Col 3, Col 4.
html = (
"<table>"
"<tr><th>Name</th><th>Age</th></tr>"
"<tr><td>Alice</td><td>30</td><td>Portland</td><td>Engineer</td></tr>"
"<tr><td>Bob</td><td>25</td><td>Seattle</td><td>Designer</td></tr>"
"</table>"
)
with patch("mcp_clipboard.server.read_clipboard", side_effect=_mock_read(html=html)):
result = await clipboard_paste(output_format="markdown", include_schema=True)

assert "Column types" in result
# Original headers preserved
assert "| Name" in result
assert "| Age" in result
# Synthetic labels filled in for the wider data rows
assert "Col 3" in result
assert "Col 4" in result


@pytest.mark.asyncio
async def test_paste_slack_format():
"""clipboard_paste returns Slack-formatted table."""
Expand Down Expand Up @@ -1050,16 +1115,14 @@ def _reset_backend_cache():

Isolates tests from each other: any test that mutates ``cb._backend``
(directly or via ``_get_backend``) must not leak state to the next test.
Prior code did this manually with trailing ``cb._backend = None`` lines,
which was brittle (easy to forget; no enforcement).
"""
import mcp_clipboard.clipboard as cb
from mcp_clipboard.clipboard import reset_backend_cache

cb._backend = None
reset_backend_cache()
try:
yield
finally:
cb._backend = None
reset_backend_cache()


def test_detect_backend_darwin():
Expand Down