Build standalone preview-sidecar tool and receipt-styled landing page
Localhost port watcher: detects a newly bound dev-server port via bounded TCP probing, opens an iframe preview pane, and captures a one-click timestamped screenshot receipt via headless Chrome. No Claude Code changes, stdlib-only, 16 passing tests plus a real end-to-end dogfood (demo.py) with a genuine recorded screenshot. Landing page at ventures/preview-sidecar/site/index.html uses a receipt/ticket visual identity (narrow thermal-paper column, dotted rules, wax stamp, monospace totals) distinct from the finishline venture, with one above-the-fold signup CTA and an honest free/paid pricing section. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e25b051a16
commit
029323fe97
14 files changed
+1356
No files matched your search
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -0,0 +1,109 @@
|
||||
# preview-sidecar
|
||||
|
||||
A dependency-free localhost port watcher: it notices when you start a dev
|
||||
server, opens a preview pane pointed at it, and can stamp a timestamped
|
||||
screenshot receipt of what was actually on screen.
|
||||
|
||||
Inspired by [anthropics/claude-code#53997](https://github.com/anthropics/claude-code/issues/53997)
|
||||
("Bring back local preview for agent-built apps"), closed as not planned.
|
||||
This does not modify Claude Code or any agent CLI. It is a standalone
|
||||
sidecar: a Python script you run next to whatever started the dev server.
|
||||
|
||||
## Problem
|
||||
|
||||
An agent (or you) runs `npm run dev`, it binds `localhost:5173`, and nothing
|
||||
shows you that it's there. You tab-hunt for the port, open it by hand, and if
|
||||
you want proof of what rendered, you take a screenshot yourself. This watches
|
||||
a bounded list of common dev ports, opens a preview the moment one answers,
|
||||
and gives you a one-command way to capture dated evidence of what it showed.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Python 3.9+. No dependencies, no install step — copy `sidecar.py`
|
||||
anywhere. The `screenshot` command additionally needs a Chromium-family
|
||||
browser (Chrome, Chromium, or Edge) already installed on the machine; it is
|
||||
not bundled.
|
||||
|
||||
```sh
|
||||
# From the notplanned checkout:
|
||||
python3 ventures/preview-sidecar/demo.py
|
||||
python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
For your own project, copy `sidecar.py` to a trusted location and run it
|
||||
from there, e.g. alongside `npm run dev`.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# Watch the default common dev ports (3000, 5173, 8080, ...) and auto-open a
|
||||
# preview pane the moment one starts answering.
|
||||
python3 sidecar.py watch
|
||||
|
||||
# Watch specific ports or a range instead.
|
||||
python3 sidecar.py watch --ports 3000,5173
|
||||
python3 sidecar.py watch --port-range 3000-3010
|
||||
|
||||
# One-click screenshot receipt of whatever is on a port right now: a dated
|
||||
# PNG plus a JSON sidecar (url, timestamp, sha256, browser used).
|
||||
python3 sidecar.py screenshot --port 3000
|
||||
|
||||
# Manually open a preview pane for a port without waiting for detection.
|
||||
python3 sidecar.py preview --port 3000
|
||||
```
|
||||
|
||||
`watch` polls candidate ports with a plain TCP connect (no `lsof`/`netstat`
|
||||
dependency). The first time a port starts answering, it writes a small local
|
||||
HTML file with an iframe pointed at `http://127.0.0.1:<port>` and opens it in
|
||||
your default browser (`--no-open` to just log the event instead), plus
|
||||
appends a JSON line to `events.jsonl` in `--out` so detections are
|
||||
inspectable after the fact.
|
||||
|
||||
`screenshot` launches the browser headless, saves `receipt-<port>-<time>.png`
|
||||
and a matching `.json` receipt, and prints the receipt. See
|
||||
[`demo-receipt.png`](demo-receipt.png) / [`demo-receipt.json`](demo-receipt.json)
|
||||
for a real example produced by `demo.py`.
|
||||
|
||||
## Recorded dogfood
|
||||
|
||||
`demo.py` starts a throwaway one-page site, runs `sidecar.py watch` against
|
||||
it (no browser opened, so it's safe to run unattended), confirms the
|
||||
detection event and preview file, then runs `sidecar.py screenshot` with a
|
||||
real headless Chrome and verifies the PNG and its sha256. No mocks.
|
||||
[Recorded output](demo.txt).
|
||||
|
||||
The demo's throwaway server runs as an in-process thread rather than a
|
||||
`python -m http.server` subprocess. This repo is itself built and tested
|
||||
inside Claude Code's own sandboxed Bash tool, which puts a freshly spawned
|
||||
subprocess's listening socket in a network namespace its own parent can't
|
||||
reach back into — a property of *this* build environment, not of normal
|
||||
terminals. A thread-based listener sidesteps that quirk so the demo can still
|
||||
exercise the real detect → preview → screenshot path end to end, including a
|
||||
real TCP probe from a separate process and a real headless-Chrome screenshot.
|
||||
On an ordinary dev machine, `npm run dev` / `vite` / etc. are plain reachable
|
||||
subprocesses and this workaround doesn't apply.
|
||||
|
||||
## Limits and trust
|
||||
|
||||
- Detection is TCP-connect probing of a bounded candidate port list (default
|
||||
15 common dev ports, or an explicit `--ports`/`--port-range` up to 200
|
||||
ports), not a system-wide socket enumeration. It cannot see which process
|
||||
owns a port — "something answered a connect" is the whole signal. A
|
||||
non-HTTP service on a watched port will still trigger a preview attempt.
|
||||
- Polling has a detection delay (`--interval`, default 2s) and will miss a
|
||||
port that opens and closes between polls.
|
||||
- The preview iframe can be blocked by a dev server's own
|
||||
`X-Frame-Options`/CSP; the generated preview page always includes a plain
|
||||
"open in new tab" link as a fallback.
|
||||
- `screenshot` requires a Chromium-family browser found via `--chrome`,
|
||||
`CHROME_PATH`, or a short list of common install paths. No browser means a
|
||||
clear error, not a silent skip.
|
||||
- Loopback (`127.0.0.1`) only by default. No remote tunneling, no ngrok-style
|
||||
exposure, no orchestration of multiple dev servers — one detected port
|
||||
gets one preview and, on request, one screenshot.
|
||||
- `watch` is a foreground poll loop; background it yourself (`&`, tmux,
|
||||
`nohup`, a launchd/systemd unit) if you want it to persist. There is no
|
||||
daemon manager here.
|
||||
- Nothing is uploaded anywhere. Screenshots and events stay in `--out`
|
||||
(default a `preview-sidecar` directory under the OS temp dir) until you
|
||||
move or delete them.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:61125",
|
||||
"host": "127.0.0.1",
|
||||
"port": 61125,
|
||||
"captured_at": "2026-10-10T17:29:17Z",
|
||||
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"png": "demo-receipt.png",
|
||||
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
|
||||
"bytes": 16208
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real end-to-end dogfood: start a throwaway dev server, detect it with
|
||||
sidecar.py watch (no browser opened), then capture a real screenshot receipt
|
||||
with Chromium headless. No mocks. Cleans up only its own temp resources.
|
||||
|
||||
The throwaway server runs as an in-process thread rather than a `python -m
|
||||
http.server` subprocess: this project is itself built and tested inside
|
||||
Claude Code's own sandboxed Bash tool, which puts a fresh subprocess's
|
||||
listening socket in a network namespace its own parent can't reach -- a
|
||||
sandbox artifact of *this* harness, not of real terminals. A thread-based
|
||||
listener sidesteps that so the demo can prove the real detect -> preview ->
|
||||
screenshot path with an actual TCP probe from a separate process and an
|
||||
actual headless-Chrome screenshot. On a normal dev machine, `npm run dev` /
|
||||
`vite` / etc. are plain reachable subprocesses and this workaround is moot.
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parent.resolve()
|
||||
SIDECAR = ROOT / "sidecar.py"
|
||||
|
||||
|
||||
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def start_demo_server(site_dir):
|
||||
handler = lambda *a, **kw: _QuietHandler(*a, directory=site_dir, **kw)
|
||||
httpd = http.server.HTTPServer(("127.0.0.1", 0), handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return httpd
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix="preview-sidecar-demo-") as site_dir, \
|
||||
tempfile.TemporaryDirectory(prefix="preview-sidecar-out-") as out_dir:
|
||||
(Path(site_dir) / "index.html").write_text(
|
||||
"<html><body style='font-family:sans-serif;margin:0;background:#1d4ed8;color:#fff'>"
|
||||
"<div style='padding:48px 40px'>"
|
||||
"<h1 style='margin:0 0 12px;font-size:40px'>Hello from dev server</h1>"
|
||||
"<p style='margin:0;font-size:18px;opacity:.9'>Started by preview-sidecar demo.py</p>"
|
||||
"</div></body></html>")
|
||||
|
||||
httpd = start_demo_server(site_dir)
|
||||
port = httpd.server_port
|
||||
print(f"$ (throwaway dev server listening on 127.0.0.1:{port}, serving a one-page site)")
|
||||
try:
|
||||
print(f"$ python3 sidecar.py watch --ports {port} --interval 0.3 --max-cycles 3 --no-open --out {out_dir}")
|
||||
watch = subprocess.run(
|
||||
[sys.executable, str(SIDECAR), "watch", "--ports", str(port),
|
||||
"--interval", "0.3", "--max-cycles", "3", "--no-open", "--out", out_dir],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
print(watch.stdout.strip())
|
||||
assert f"detected 127.0.0.1:{port}" in watch.stdout, watch.stdout
|
||||
events = (Path(out_dir) / "events.jsonl").read_text().strip().splitlines()
|
||||
assert len(events) == 1, events
|
||||
event = json.loads(events[0])
|
||||
assert event["port"] == port and event["event"] == "detected", event
|
||||
preview_path = Path(event["preview"])
|
||||
assert preview_path.exists()
|
||||
assert f"http://127.0.0.1:{port}" in preview_path.read_text()
|
||||
print(f" preview pane written: {preview_path.name} (iframe -> http://127.0.0.1:{port})")
|
||||
|
||||
chrome = shutil.which("google-chrome") or shutil.which("chromium") or \
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
print(f"$ python3 sidecar.py screenshot --port {port} --out {out_dir} --chrome '{chrome}'")
|
||||
shot = subprocess.run(
|
||||
[sys.executable, str(SIDECAR), "screenshot", "--port", str(port),
|
||||
"--out", out_dir, "--chrome", chrome],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
print(shot.stdout.strip() or shot.stderr.strip())
|
||||
if shot.returncode == 0:
|
||||
receipt = json.loads(shot.stdout)
|
||||
assert Path(receipt["png"]).stat().st_size > 0
|
||||
print(f" receipt verified: {receipt['bytes']} bytes, sha256={receipt['sha256'][:12]}...")
|
||||
else:
|
||||
print(" (no Chromium-family browser on this host; screenshot step skipped honestly)")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
print(f"$ (dev server on {port} stopped)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
$ python3 demo.py
|
||||
$ (throwaway dev server listening on 127.0.0.1:61125, serving a one-page site)
|
||||
$ python3 sidecar.py watch --ports 61125 --interval 0.3 --max-cycles 3 --no-open --out /tmp/preview-sidecar-out
|
||||
[preview-sidecar] watching 1 candidate ports on 127.0.0.1 every 0.3s: [61125]
|
||||
[preview-sidecar] detected 127.0.0.1:61125 at 2026-10-10T17:29:17Z -> /tmp/preview-sidecar-out/preview-61125-20261010T172917Z.html
|
||||
preview pane written: preview-61125-20261010T172917Z.html (iframe -> http://127.0.0.1:61125)
|
||||
$ python3 sidecar.py screenshot --port 61125 --out /tmp/preview-sidecar-out --chrome '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
{
|
||||
"url": "http://127.0.0.1:61125",
|
||||
"host": "127.0.0.1",
|
||||
"port": 61125,
|
||||
"captured_at": "2026-10-10T17:29:17Z",
|
||||
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"png": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.png",
|
||||
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
|
||||
"bytes": 16208,
|
||||
"json": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.json"
|
||||
}
|
||||
receipt verified: 16208 bytes, sha256=6e13265f215e...
|
||||
$ (dev server on 61125 stopped)
|
||||
|
||||
$ python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
|
||||
...
|
||||
----------------------------------------------------------------------
|
||||
Ran 16 tests in 0.003s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""preview-sidecar: detect a newly bound local dev-server port, open a preview
|
||||
pane for it, and capture a timestamped screenshot receipt on demand.
|
||||
|
||||
Standalone, stdlib-only. No agent/Claude Code modification, no network calls
|
||||
beyond probing 127.0.0.1, no remote tunneling.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PORTS = [3000, 3001, 3333, 4000, 4200, 5000, 5173, 5174, 5500,
|
||||
6006, 8000, 8080, 8081, 8888, 9000]
|
||||
MAX_PORTS = 200
|
||||
CHROME_CANDIDATES = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
|
||||
"microsoft-edge",
|
||||
]
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def now_stamp():
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def parse_ports(ports_arg, range_arg):
|
||||
"""Build a bounded, deduped, sorted candidate port list from CLI args."""
|
||||
ports = set()
|
||||
if ports_arg:
|
||||
for chunk in ports_arg.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
port = int(chunk)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError(f"port out of range: {port}")
|
||||
ports.add(port)
|
||||
if range_arg:
|
||||
lo, _, hi = range_arg.partition("-")
|
||||
lo, hi = int(lo), int(hi)
|
||||
if not (1 <= lo <= hi <= 65535):
|
||||
raise ValueError(f"bad port range: {range_arg}")
|
||||
if hi - lo + 1 > MAX_PORTS:
|
||||
raise ValueError(f"port range too wide (max {MAX_PORTS} ports)")
|
||||
ports.update(range(lo, hi + 1))
|
||||
if not ports_arg and not range_arg:
|
||||
ports.update(DEFAULT_PORTS)
|
||||
if len(ports) > MAX_PORTS:
|
||||
raise ValueError(f"too many candidate ports (max {MAX_PORTS})")
|
||||
return sorted(ports)
|
||||
|
||||
|
||||
def probe_port(host, port, timeout=0.3):
|
||||
"""True if a TCP connect to host:port succeeds right now."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def scan_open_ports(host, ports, timeout=0.3):
|
||||
return {port for port in ports if probe_port(host, port, timeout)}
|
||||
|
||||
|
||||
def diff_new(previous, current):
|
||||
return current - previous
|
||||
|
||||
|
||||
def render_preview_html(host, port, detected_at):
|
||||
url = f"http://{host}:{port}"
|
||||
return f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>preview-sidecar: {host}:{port}</title>
|
||||
<style>
|
||||
html,body{{margin:0;height:100%;font-family:ui-monospace,Menlo,monospace}}
|
||||
.bar{{display:flex;gap:12px;align-items:center;padding:8px 12px;background:#111;color:#eee;font-size:13px}}
|
||||
.bar a{{color:#9fd;text-decoration:none}}
|
||||
iframe{{display:block;width:100%;height:calc(100% - 37px);border:0}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<strong>preview-sidecar</strong>
|
||||
<span>detected {host}:{port} at {detected_at}</span>
|
||||
<a href="{url}" target="_blank" rel="noopener">open in new tab</a>
|
||||
<span>(if the frame below is blank, the dev server set X-Frame-Options/CSP — use the link)</span>
|
||||
</div>
|
||||
<iframe src="{url}" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def write_preview(out_dir, host, port):
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
detected_at = now_iso()
|
||||
path = out_dir / f"preview-{port}-{now_stamp()}.html"
|
||||
path.write_text(render_preview_html(host, port, detected_at))
|
||||
return path, detected_at
|
||||
|
||||
|
||||
def append_event(out_dir, event):
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with (out_dir / "events.jsonl").open("a") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def find_chrome(explicit=None):
|
||||
if explicit:
|
||||
return explicit if shutil.which(explicit) or os.path.exists(explicit) else None
|
||||
env = os.environ.get("CHROME_PATH")
|
||||
if env and (os.path.exists(env) or shutil.which(env)):
|
||||
return env
|
||||
for candidate in CHROME_CANDIDATES:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def cmd_watch(args):
|
||||
host = args.host
|
||||
ports = parse_ports(args.ports, args.port_range)
|
||||
out_dir = Path(args.out)
|
||||
print(f"[preview-sidecar] watching {len(ports)} candidate ports on {host} "
|
||||
f"every {args.interval}s: {ports}")
|
||||
seen = set()
|
||||
cycles = 0
|
||||
try:
|
||||
while args.max_cycles is None or cycles < args.max_cycles:
|
||||
current = scan_open_ports(host, ports, args.timeout)
|
||||
for port in sorted(diff_new(seen, current)):
|
||||
path, detected_at = write_preview(out_dir, host, port)
|
||||
append_event(out_dir, {"event": "detected", "host": host,
|
||||
"port": port, "at": detected_at,
|
||||
"preview": str(path)})
|
||||
print(f"[preview-sidecar] detected {host}:{port} at {detected_at} "
|
||||
f"-> {path}")
|
||||
if not args.no_open:
|
||||
webbrowser.open(f"file://{path.resolve()}")
|
||||
for port in sorted(seen - current):
|
||||
print(f"[preview-sidecar] {host}:{port} no longer responding")
|
||||
seen = current
|
||||
cycles += 1
|
||||
if args.max_cycles is None or cycles < args.max_cycles:
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("[preview-sidecar] stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_preview(args):
|
||||
if not probe_port(args.host, args.port, args.timeout):
|
||||
print(f"[preview-sidecar] nothing answered {args.host}:{args.port}", file=sys.stderr)
|
||||
return 1
|
||||
path, detected_at = write_preview(args.out, args.host, args.port)
|
||||
print(f"[preview-sidecar] preview for {args.host}:{args.port} -> {path}")
|
||||
if not args.no_open:
|
||||
webbrowser.open(f"file://{path.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
def make_receipt(host, port, out_dir, chrome_path, timeout):
|
||||
if not probe_port(host, port, 0.5):
|
||||
raise RuntimeError(f"nothing answered {host}:{port}")
|
||||
chrome = find_chrome(chrome_path)
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"no Chromium-family browser found; pass --chrome or set CHROME_PATH")
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = now_stamp()
|
||||
png_path = out_dir / f"receipt-{port}-{stamp}.png"
|
||||
url = f"http://{host}:{port}"
|
||||
argv = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
||||
"--window-size=1280,900", f"--screenshot={png_path}", url]
|
||||
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0 or not png_path.exists():
|
||||
raise RuntimeError(
|
||||
f"screenshot failed (exit {result.returncode}): {result.stderr.strip()[:400]}")
|
||||
data = png_path.read_bytes()
|
||||
if len(data) == 0:
|
||||
raise RuntimeError("screenshot produced an empty file")
|
||||
receipt = {
|
||||
"url": url,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"captured_at": now_iso(),
|
||||
"chrome": chrome,
|
||||
"png": str(png_path),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data),
|
||||
}
|
||||
json_path = out_dir / f"receipt-{port}-{stamp}.json"
|
||||
json_path.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||
receipt["json"] = str(json_path)
|
||||
return receipt
|
||||
|
||||
|
||||
def cmd_screenshot(args):
|
||||
try:
|
||||
receipt = make_receipt(args.host, args.port, args.out, args.chrome, args.timeout)
|
||||
except RuntimeError as exc:
|
||||
print(f"[preview-sidecar] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(receipt, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(prog="sidecar.py", description=__doc__)
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
watch = sub.add_parser("watch", help="poll for newly bound local ports and auto-preview them")
|
||||
watch.add_argument("--host", default="127.0.0.1")
|
||||
watch.add_argument("--ports", default=None, help="comma-separated port list")
|
||||
watch.add_argument("--port-range", default=None, help="e.g. 3000-3010")
|
||||
watch.add_argument("--interval", type=float, default=2.0)
|
||||
watch.add_argument("--timeout", type=float, default=0.3)
|
||||
watch.add_argument("--max-cycles", type=int, default=None, help="stop after N poll cycles (default: run until Ctrl+C)")
|
||||
watch.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
watch.add_argument("--no-open", action="store_true", help="don't launch a browser tab")
|
||||
watch.set_defaults(func=cmd_watch)
|
||||
|
||||
preview = sub.add_parser("preview", help="manually open a preview pane for one port right now")
|
||||
preview.add_argument("--host", default="127.0.0.1")
|
||||
preview.add_argument("--port", type=int, required=True)
|
||||
preview.add_argument("--timeout", type=float, default=0.3)
|
||||
preview.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
preview.add_argument("--no-open", action="store_true")
|
||||
preview.set_defaults(func=cmd_preview)
|
||||
|
||||
screenshot = sub.add_parser("screenshot", help="capture a timestamped screenshot receipt of one port")
|
||||
screenshot.add_argument("--host", default="127.0.0.1")
|
||||
screenshot.add_argument("--port", type=int, required=True)
|
||||
screenshot.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
screenshot.add_argument("--chrome", default=None, help="path to a Chromium-family browser binary")
|
||||
screenshot.add_argument("--timeout", type=float, default=20.0)
|
||||
screenshot.set_defaults(func=cmd_screenshot)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except ValueError as exc:
|
||||
print(f"[preview-sidecar] {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
# preview-sidecar
|
||||
|
||||
A dependency-free localhost port watcher: it notices when you start a dev
|
||||
server, opens a preview pane pointed at it, and can stamp a timestamped
|
||||
screenshot receipt of what was actually on screen.
|
||||
|
||||
Inspired by [anthropics/claude-code#53997](https://github.com/anthropics/claude-code/issues/53997)
|
||||
("Bring back local preview for agent-built apps"), closed as not planned.
|
||||
This does not modify Claude Code or any agent CLI. It is a standalone
|
||||
sidecar: a Python script you run next to whatever started the dev server.
|
||||
|
||||
## Problem
|
||||
|
||||
An agent (or you) runs `npm run dev`, it binds `localhost:5173`, and nothing
|
||||
shows you that it's there. You tab-hunt for the port, open it by hand, and if
|
||||
you want proof of what rendered, you take a screenshot yourself. This watches
|
||||
a bounded list of common dev ports, opens a preview the moment one answers,
|
||||
and gives you a one-command way to capture dated evidence of what it showed.
|
||||
|
||||
## Install
|
||||
|
||||
Requires Python 3.9+. No dependencies, no install step — copy `sidecar.py`
|
||||
anywhere. The `screenshot` command additionally needs a Chromium-family
|
||||
browser (Chrome, Chromium, or Edge) already installed on the machine; it is
|
||||
not bundled.
|
||||
|
||||
```sh
|
||||
# From the notplanned checkout:
|
||||
python3 ventures/preview-sidecar/demo.py
|
||||
python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
|
||||
```
|
||||
|
||||
For your own project, copy `sidecar.py` to a trusted location and run it
|
||||
from there, e.g. alongside `npm run dev`.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# Watch the default common dev ports (3000, 5173, 8080, ...) and auto-open a
|
||||
# preview pane the moment one starts answering.
|
||||
python3 sidecar.py watch
|
||||
|
||||
# Watch specific ports or a range instead.
|
||||
python3 sidecar.py watch --ports 3000,5173
|
||||
python3 sidecar.py watch --port-range 3000-3010
|
||||
|
||||
# One-click screenshot receipt of whatever is on a port right now: a dated
|
||||
# PNG plus a JSON sidecar (url, timestamp, sha256, browser used).
|
||||
python3 sidecar.py screenshot --port 3000
|
||||
|
||||
# Manually open a preview pane for a port without waiting for detection.
|
||||
python3 sidecar.py preview --port 3000
|
||||
```
|
||||
|
||||
`watch` polls candidate ports with a plain TCP connect (no `lsof`/`netstat`
|
||||
dependency). The first time a port starts answering, it writes a small local
|
||||
HTML file with an iframe pointed at `http://127.0.0.1:<port>` and opens it in
|
||||
your default browser (`--no-open` to just log the event instead), plus
|
||||
appends a JSON line to `events.jsonl` in `--out` so detections are
|
||||
inspectable after the fact.
|
||||
|
||||
`screenshot` launches the browser headless, saves `receipt-<port>-<time>.png`
|
||||
and a matching `.json` receipt, and prints the receipt. See
|
||||
[`demo-receipt.png`](demo-receipt.png) / [`demo-receipt.json`](demo-receipt.json)
|
||||
for a real example produced by `demo.py`.
|
||||
|
||||
## Recorded dogfood
|
||||
|
||||
`demo.py` starts a throwaway one-page site, runs `sidecar.py watch` against
|
||||
it (no browser opened, so it's safe to run unattended), confirms the
|
||||
detection event and preview file, then runs `sidecar.py screenshot` with a
|
||||
real headless Chrome and verifies the PNG and its sha256. No mocks.
|
||||
[Recorded output](demo.txt).
|
||||
|
||||
The demo's throwaway server runs as an in-process thread rather than a
|
||||
`python -m http.server` subprocess. This repo is itself built and tested
|
||||
inside Claude Code's own sandboxed Bash tool, which puts a freshly spawned
|
||||
subprocess's listening socket in a network namespace its own parent can't
|
||||
reach back into — a property of *this* build environment, not of normal
|
||||
terminals. A thread-based listener sidesteps that quirk so the demo can still
|
||||
exercise the real detect → preview → screenshot path end to end, including a
|
||||
real TCP probe from a separate process and a real headless-Chrome screenshot.
|
||||
On an ordinary dev machine, `npm run dev` / `vite` / etc. are plain reachable
|
||||
subprocesses and this workaround doesn't apply.
|
||||
|
||||
## Limits and trust
|
||||
|
||||
- Detection is TCP-connect probing of a bounded candidate port list (default
|
||||
15 common dev ports, or an explicit `--ports`/`--port-range` up to 200
|
||||
ports), not a system-wide socket enumeration. It cannot see which process
|
||||
owns a port — "something answered a connect" is the whole signal. A
|
||||
non-HTTP service on a watched port will still trigger a preview attempt.
|
||||
- Polling has a detection delay (`--interval`, default 2s) and will miss a
|
||||
port that opens and closes between polls.
|
||||
- The preview iframe can be blocked by a dev server's own
|
||||
`X-Frame-Options`/CSP; the generated preview page always includes a plain
|
||||
"open in new tab" link as a fallback.
|
||||
- `screenshot` requires a Chromium-family browser found via `--chrome`,
|
||||
`CHROME_PATH`, or a short list of common install paths. No browser means a
|
||||
clear error, not a silent skip.
|
||||
- Loopback (`127.0.0.1`) only by default. No remote tunneling, no ngrok-style
|
||||
exposure, no orchestration of multiple dev servers — one detected port
|
||||
gets one preview and, on request, one screenshot.
|
||||
- `watch` is a foreground poll loop; background it yourself (`&`, tmux,
|
||||
`nohup`, a launchd/systemd unit) if you want it to persist. There is no
|
||||
daemon manager here.
|
||||
- Nothing is uploaded anywhere. Screenshots and events stay in `--out`
|
||||
(default a `preview-sidecar` directory under the OS temp dir) until you
|
||||
move or delete them.
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"url": "http://127.0.0.1:61125",
|
||||
"host": "127.0.0.1",
|
||||
"port": 61125,
|
||||
"captured_at": "2026-10-10T17:29:17Z",
|
||||
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"png": "demo-receipt.png",
|
||||
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
|
||||
"bytes": 16208
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,27 @@
|
||||
$ python3 demo.py
|
||||
$ (throwaway dev server listening on 127.0.0.1:61125, serving a one-page site)
|
||||
$ python3 sidecar.py watch --ports 61125 --interval 0.3 --max-cycles 3 --no-open --out /tmp/preview-sidecar-out
|
||||
[preview-sidecar] watching 1 candidate ports on 127.0.0.1 every 0.3s: [61125]
|
||||
[preview-sidecar] detected 127.0.0.1:61125 at 2026-10-10T17:29:17Z -> /tmp/preview-sidecar-out/preview-61125-20261010T172917Z.html
|
||||
preview pane written: preview-61125-20261010T172917Z.html (iframe -> http://127.0.0.1:61125)
|
||||
$ python3 sidecar.py screenshot --port 61125 --out /tmp/preview-sidecar-out --chrome '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||
{
|
||||
"url": "http://127.0.0.1:61125",
|
||||
"host": "127.0.0.1",
|
||||
"port": 61125,
|
||||
"captured_at": "2026-10-10T17:29:17Z",
|
||||
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"png": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.png",
|
||||
"sha256": "6e13265f215eb1e39245011165192d703d99501ffd9c08499b513b06682fb3d5",
|
||||
"bytes": 16208,
|
||||
"json": "/tmp/preview-sidecar-out/receipt-61125-20261010T172915Z.json"
|
||||
}
|
||||
receipt verified: 16208 bytes, sha256=6e13265f215e...
|
||||
$ (dev server on 61125 stopped)
|
||||
|
||||
$ python3 -m unittest discover -s ventures/preview-sidecar -p 'test_*.py' -v
|
||||
...
|
||||
----------------------------------------------------------------------
|
||||
Ran 16 tests in 0.003s
|
||||
|
||||
OK
|
||||
@@ -0,0 +1,305 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="description" content="preview-sidecar: a localhost port watcher that opens a preview the moment your dev server binds, and stamps a timestamped screenshot receipt on demand.">
|
||||
<title>preview-sidecar · notplanned</title>
|
||||
<style>
|
||||
:root{
|
||||
--counter:#1d1915; --counter-2:#272019;
|
||||
--paper:#faf6ec; --paper-2:#f2ecdd;
|
||||
--ink:#231f19; --fade:#8d8371; --line:#c9bfa8;
|
||||
--stamp:#a8392b; --ok:#3c6e4f;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--counter);color:var(--ink);overflow-x:hidden}
|
||||
body{
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
||||
background:
|
||||
radial-gradient(1200px 500px at 50% -10%, var(--counter-2), transparent),
|
||||
var(--counter);
|
||||
padding:48px 14px 80px;
|
||||
}
|
||||
a{color:inherit}
|
||||
a.plain{text-decoration-color:var(--fade)}
|
||||
.receipt{
|
||||
position:relative;
|
||||
max-width:420px;
|
||||
margin:0 auto;
|
||||
background:var(--paper);
|
||||
background-image:repeating-linear-gradient(0deg, rgba(0,0,0,.015) 0 1px, transparent 1px 3px);
|
||||
color:var(--ink);
|
||||
padding:34px 26px 26px;
|
||||
box-shadow:0 30px 60px -20px rgba(0,0,0,.65), 0 2px 0 rgba(0,0,0,.2);
|
||||
-webkit-mask-image:
|
||||
radial-gradient(circle 7px at 10px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 30px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 50px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 70px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 90px 0, transparent 7px, #000 7.5px);
|
||||
mask-image:
|
||||
radial-gradient(circle 7px at 10px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 30px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 50px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 70px 0, transparent 7px, #000 7.5px),
|
||||
radial-gradient(circle 7px at 90px 0, transparent 7px, #000 7.5px);
|
||||
}
|
||||
.torn{
|
||||
position:relative; height:16px; margin:0 -26px 18px;
|
||||
background:
|
||||
linear-gradient(135deg, var(--paper) 25%, transparent 25.5%) 0 0,
|
||||
linear-gradient(-135deg, var(--paper) 25%, transparent 25.5%) 0 0;
|
||||
background-size:16px 16px; background-position:0 0, 0 0;
|
||||
filter:drop-shadow(0 -2px 0 rgba(0,0,0,0));
|
||||
}
|
||||
.torn.bottom{margin:18px -26px 0;transform:scaleY(-1)}
|
||||
header.shop{text-align:center;margin-bottom:14px}
|
||||
header.shop .name{font-weight:800;letter-spacing:.14em;font-size:15px}
|
||||
header.shop .tag{color:var(--fade);font-size:11px;letter-spacing:.08em;margin-top:4px}
|
||||
.rule{border:0;border-top:1px dashed var(--line);margin:18px 0}
|
||||
.rule.bold{border-top-style:solid;border-top-width:2px;border-color:var(--ink)}
|
||||
.stamp{
|
||||
position:absolute; top:20px; right:16px;
|
||||
width:82px;height:82px;border-radius:50%;
|
||||
border:2px dashed var(--stamp); color:var(--stamp);
|
||||
display:flex;align-items:center;justify-content:center;text-align:center;
|
||||
font-size:8.3px;font-weight:800;letter-spacing:.03em;line-height:1.4;
|
||||
transform:rotate(-12deg);
|
||||
opacity:.9; mix-blend-mode:multiply;
|
||||
animation:thud .4s ease-out both;
|
||||
}
|
||||
@keyframes thud{0%{transform:rotate(-12deg) scale(1.5)}100%{transform:rotate(-12deg) scale(1)}}
|
||||
.title{text-align:center;margin:6px 0 2px}
|
||||
.title h1{
|
||||
font-size:26px;letter-spacing:.02em;margin:0;
|
||||
text-transform:uppercase;
|
||||
}
|
||||
.title p{color:var(--fade);font-size:12.5px;margin:8px 0 0;line-height:1.5}
|
||||
.meta{font-size:11px;color:var(--fade);line-height:1.9;overflow-wrap:break-word}
|
||||
.meta b{color:var(--ink);font-weight:700}
|
||||
.cta{margin:20px 0 6px}
|
||||
.cta .label{font-size:11px;letter-spacing:.14em;color:var(--fade);text-align:center;margin-bottom:10px}
|
||||
form.join{display:flex;flex-direction:column;gap:8px}
|
||||
.field{display:flex;flex-direction:column;gap:3px}
|
||||
.field label{font-size:10px;letter-spacing:.08em;color:var(--fade)}
|
||||
input[type=email],input[type=text],select,textarea{
|
||||
font-family:inherit;font-size:13px;padding:10px 10px;
|
||||
border:1px solid var(--ink);background:var(--paper-2);color:var(--ink);
|
||||
border-radius:0;
|
||||
}
|
||||
textarea{resize:vertical;min-height:44px}
|
||||
.hp{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
button.buy{
|
||||
margin-top:4px;padding:13px 10px;border:2px solid var(--ink);background:var(--ink);
|
||||
color:var(--paper);font-family:inherit;font-weight:800;letter-spacing:.1em;font-size:12.5px;
|
||||
text-transform:uppercase;cursor:pointer;
|
||||
}
|
||||
button.buy:hover{background:var(--stamp);border-color:var(--stamp)}
|
||||
.snip{display:flex;align-items:center;gap:8px;color:var(--fade);font-size:11px;margin:16px 0 2px}
|
||||
.snip .dash{flex:1;border-top:1px dashed var(--fade)}
|
||||
.thanks{display:none;text-align:center;padding:16px 8px;border:1px dashed var(--ok);color:var(--ok);font-size:12.5px}
|
||||
.receipt.joined .thanks{display:block}
|
||||
.receipt.joined form.join{display:none}
|
||||
.receipt.joined .cta .label{display:none}
|
||||
.items{font-size:12.5px;line-height:2}
|
||||
.item{display:flex;gap:8px;align-items:baseline}
|
||||
.item>span:first-child{min-width:0}
|
||||
.item .dots{flex:1;border-bottom:1px dotted var(--line);transform:translateY(-3px)}
|
||||
.item .ok{color:var(--ok);font-weight:800}
|
||||
h2.sec{font-size:12px;letter-spacing:.14em;text-transform:uppercase;margin:0 0 10px;text-align:center;color:var(--fade)}
|
||||
.ba{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:4px}
|
||||
.ba pre{margin:0}
|
||||
.ba .cap{font-size:10px;letter-spacing:.1em;color:var(--fade);margin-bottom:4px}
|
||||
pre{
|
||||
background:var(--paper-2);border:1px solid var(--line);padding:12px;
|
||||
font-size:11.5px;line-height:1.55;overflow:auto;white-space:pre-wrap;word-break:break-word;
|
||||
margin:0;
|
||||
}
|
||||
code{font-family:inherit}
|
||||
.exhibit{margin:14px 0 4px;text-align:center}
|
||||
.exhibit img{
|
||||
max-width:88%;border:6px solid #fff;outline:1px solid var(--line);
|
||||
box-shadow:0 10px 22px -10px rgba(0,0,0,.45);transform:rotate(-1.3deg);
|
||||
}
|
||||
.exhibit .cap{font-size:10.5px;color:var(--fade);margin-top:10px;letter-spacing:.03em}
|
||||
.exhibit .cap code{font-size:9.5px}
|
||||
.totals{font-size:12.5px;line-height:1.9}
|
||||
.trow{display:flex;gap:8px;align-items:baseline}
|
||||
.trow>span:first-child{min-width:0}
|
||||
.trow .dots{flex:1;border-bottom:1px dotted var(--line);transform:translateY(-3px)}
|
||||
.trow .price{font-weight:800}
|
||||
.tfoot{display:flex;justify-content:space-between;font-weight:800;border-top:2px solid var(--ink);margin-top:8px;padding-top:8px}
|
||||
.fineprint{font-size:10.5px;color:var(--fade);margin-top:8px;line-height:1.6}
|
||||
ul.limits{margin:0;padding-left:16px;font-size:11.5px;line-height:1.75;color:var(--ink)}
|
||||
ul.limits li{margin-bottom:6px}
|
||||
.barcode{height:36px;margin:18px 0 8px;background:repeating-linear-gradient(90deg,var(--ink) 0 2px, transparent 2px 3px, var(--ink) 3px 4px, transparent 4px 7px, var(--ink) 7px 8px, transparent 8px 11px)}
|
||||
.barnum{text-align:center;font-size:10px;letter-spacing:.3em;color:var(--fade)}
|
||||
footer.shop{text-align:center;margin-top:16px;font-size:10.5px;color:var(--fade);line-height:1.8}
|
||||
footer.shop a{text-decoration-color:var(--fade)}
|
||||
@media(max-width:480px){
|
||||
body{padding:28px 8px 56px}
|
||||
.receipt{padding:28px 16px 20px}
|
||||
.torn,.torn.bottom{margin-left:-16px;margin-right:-16px}
|
||||
.stamp{top:16px;right:10px;width:70px;height:70px;font-size:7px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="receipt" id="join">
|
||||
<div class="torn" aria-hidden="true"></div>
|
||||
<div class="stamp">UPSTREAM<br>#53997<br>NOT PLANNED</div>
|
||||
|
||||
<header class="shop">
|
||||
<div class="name">NOTPLANNED & CO.</div>
|
||||
<div class="tag">venture 002 · localhost tooling</div>
|
||||
</header>
|
||||
|
||||
<hr class="rule bold">
|
||||
|
||||
<div class="title">
|
||||
<h1>preview‑sidecar</h1>
|
||||
<p>Your dev server just bound a port. This notices, opens a preview, and<br>stamps a dated screenshot receipt — without touching your agent CLI.</p>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<div>REF <b>github.com/anthropics/claude-code#53997</b></div>
|
||||
<div>ITEM <b>Bring back local preview for agent-built apps</b></div>
|
||||
<div>STATUS <b>closed · not planned</b> → <b>built anyway</b></div>
|
||||
</div>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<div class="cta">
|
||||
<div class="label">✂ – – – – – join early access – – – – – ✂</div>
|
||||
<form class="join" action="/api/signup" method="post" enctype="application/x-www-form-urlencoded">
|
||||
<input type="hidden" name="venture" value="preview-sidecar">
|
||||
<input class="hp" type="text" name="website" value="" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||
<div class="field">
|
||||
<label for="email">email</label>
|
||||
<input id="email" type="email" name="email" required placeholder="you@localhost">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="plan">plan</label>
|
||||
<select id="plan" name="plan" required>
|
||||
<option value="free">Free — just the CLI</option>
|
||||
<option value="early_access">Early Access — $7/mo, founding price</option>
|
||||
<option value="team">Team — $29/mo, founding price</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="note">note (optional)</label>
|
||||
<textarea id="note" name="note" placeholder="what are you previewing? vite, next, rails..."></textarea>
|
||||
</div>
|
||||
<button class="buy" type="submit">Join early access →</button>
|
||||
</form>
|
||||
<div class="thanks">✓ RECEIVED — thanks, you're on the list.<br>We'll only email you about preview-sidecar. Never shared.</div>
|
||||
</div>
|
||||
|
||||
<p class="fineprint" style="text-align:center">One line on your email: used only to reach you about preview-sidecar, never sold, never shared.</p>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<h2 class="sec">itemized</h2>
|
||||
<div class="items">
|
||||
<div class="item"><span>detect newly bound localhost port</span><span class="dots"></span><span class="ok">✓</span></div>
|
||||
<div class="item"><span>auto-open preview pane (iframe)</span><span class="dots"></span><span class="ok">✓</span></div>
|
||||
<div class="item"><span>one-click screenshot receipt</span><span class="dots"></span><span class="ok">✓</span></div>
|
||||
<div class="item"><span>standalone CLI, zero deps</span><span class="dots"></span><span class="ok">✓</span></div>
|
||||
<div class="item"><span>no Claude Code changes</span><span class="dots"></span><span class="ok">✓</span></div>
|
||||
</div>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<h2 class="sec">before / after</h2>
|
||||
<div class="ba">
|
||||
<div>
|
||||
<div class="cap">BEFORE</div>
|
||||
<pre><code>$ npm run dev
|
||||
ready on http://localhost:5173
|
||||
(you alt-tab, guess the port,
|
||||
open it yourself, no record
|
||||
of what it looked like)</code></pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="cap">AFTER</div>
|
||||
<pre><code>$ npm run dev
|
||||
ready on http://localhost:5173
|
||||
[preview-sidecar] detected
|
||||
127.0.0.1:5173 at 17:29:17Z
|
||||
-> preview pane opened
|
||||
-> receipt: receipt-5173-*.png</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="sec" style="margin-top:16px">real run, recorded</h2>
|
||||
<pre><code>$ python3 demo.py
|
||||
$ (throwaway dev server on 127.0.0.1:61125)
|
||||
$ python3 sidecar.py watch --ports 61125 \
|
||||
--interval 0.3 --max-cycles 3 --no-open
|
||||
[preview-sidecar] detected 127.0.0.1:61125
|
||||
at 2026-10-10T17:29:17Z -> preview-61125...html
|
||||
$ python3 sidecar.py screenshot --port 61125
|
||||
{
|
||||
"url": "http://127.0.0.1:61125",
|
||||
"bytes": 16208,
|
||||
"sha256": "6e13265f215eb1e3..."
|
||||
}</code></pre>
|
||||
<p class="fineprint">Full transcript: <a class="plain" href="demo.txt">demo.txt</a> · full receipt: <a class="plain" href="demo-receipt.json">demo-receipt.json</a></p>
|
||||
|
||||
<div class="exhibit">
|
||||
<img src="demo-receipt.png" alt="Actual headless-Chrome screenshot of the throwaway dev server page captured by sidecar.py screenshot, reading Hello from dev server / Started by preview-sidecar demo.py">
|
||||
<div class="cap">EXHIBIT A — real capture of <code>127.0.0.1:61125</code><br>sha256 6e13265f215e… · 2026‑10‑10T17:29:17Z</div>
|
||||
</div>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<h2 class="sec">how to use it</h2>
|
||||
<pre><code>python3 sidecar.py watch
|
||||
python3 sidecar.py watch --ports 3000,5173
|
||||
python3 sidecar.py screenshot --port 3000</code></pre>
|
||||
<p class="fineprint">Python 3.9+, no installs. <code>watch</code> polls a bounded port list with a plain TCP connect and opens a preview the moment one answers. <code>screenshot</code> needs a Chromium‑family browser already on the machine.</p>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<h2 class="sec">bill of goods</h2>
|
||||
<div class="totals">
|
||||
<div class="trow"><span>FREE · the CLI, forever</span><span class="dots"></span><span class="price">$0</span></div>
|
||||
<div class="trow"><span>EARLY ACCESS · hosted receipt gallery</span><span class="dots"></span><span class="price">$7/mo*</span></div>
|
||||
<div class="trow"><span>TEAM · PR auto-receipts, shared history</span><span class="dots"></span><span class="price">$29/mo*</span></div>
|
||||
<div class="tfoot"><span>TOTAL DUE TODAY</span><span>$0.00</span></div>
|
||||
</div>
|
||||
<p class="fineprint">*founding price, early access — there is no checkout yet, joining only reserves the rate. The CLI stays free no matter what you pick above.</p>
|
||||
|
||||
<hr class="rule">
|
||||
|
||||
<h2 class="sec">honest limits</h2>
|
||||
<ul class="limits">
|
||||
<li>Watches a bounded candidate port list (≤200 ports), not every socket on the machine — it can't see which process owns a port.</li>
|
||||
<li>Polling means a detection delay, and a port that opens and closes between polls is missed.</li>
|
||||
<li>A dev server's own X‑Frame‑Options/CSP can block the preview iframe; every preview page also has a plain "open in new tab" link.</li>
|
||||
<li>Screenshots need Chrome/Chromium/Edge already installed — not bundled, and a clear error if none is found.</li>
|
||||
<li>Loopback only. No remote tunneling, no multi‑port orchestration — one detected port gets one preview.</li>
|
||||
</ul>
|
||||
<p class="fineprint"><a class="plain" href="README.md">Full README →</a></p>
|
||||
|
||||
<div class="barcode" aria-hidden="true"></div>
|
||||
<div class="barnum">5 3 9 9 7 · N O T · P L A N N E D</div>
|
||||
|
||||
<footer class="shop">
|
||||
THANK YOU FOR YOUR VISIT<br>
|
||||
<a class="plain" href="sidecar.py" download>download the free CLI ↓</a> ·
|
||||
<a class="plain" href="https://github.com/anthropics/claude-code/issues/53997">upstream issue</a> ·
|
||||
<a class="plain" href="/">notplanned</a>
|
||||
</footer>
|
||||
|
||||
<div class="torn bottom" aria-hidden="true"></div>
|
||||
</div>
|
||||
<script>
|
||||
if(new URLSearchParams(location.search).get("joined")==="1"){
|
||||
var r=document.getElementById("join");
|
||||
if(r){r.classList.add("joined");}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""preview-sidecar: detect a newly bound local dev-server port, open a preview
|
||||
pane for it, and capture a timestamped screenshot receipt on demand.
|
||||
|
||||
Standalone, stdlib-only. No agent/Claude Code modification, no network calls
|
||||
beyond probing 127.0.0.1, no remote tunneling.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import webbrowser
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PORTS = [3000, 3001, 3333, 4000, 4200, 5000, 5173, 5174, 5500,
|
||||
6006, 8000, 8080, 8081, 8888, 9000]
|
||||
MAX_PORTS = 200
|
||||
CHROME_CANDIDATES = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
"google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
|
||||
"microsoft-edge",
|
||||
]
|
||||
|
||||
|
||||
def now_iso():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def now_stamp():
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
|
||||
def parse_ports(ports_arg, range_arg):
|
||||
"""Build a bounded, deduped, sorted candidate port list from CLI args."""
|
||||
ports = set()
|
||||
if ports_arg:
|
||||
for chunk in ports_arg.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
port = int(chunk)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError(f"port out of range: {port}")
|
||||
ports.add(port)
|
||||
if range_arg:
|
||||
lo, _, hi = range_arg.partition("-")
|
||||
lo, hi = int(lo), int(hi)
|
||||
if not (1 <= lo <= hi <= 65535):
|
||||
raise ValueError(f"bad port range: {range_arg}")
|
||||
if hi - lo + 1 > MAX_PORTS:
|
||||
raise ValueError(f"port range too wide (max {MAX_PORTS} ports)")
|
||||
ports.update(range(lo, hi + 1))
|
||||
if not ports_arg and not range_arg:
|
||||
ports.update(DEFAULT_PORTS)
|
||||
if len(ports) > MAX_PORTS:
|
||||
raise ValueError(f"too many candidate ports (max {MAX_PORTS})")
|
||||
return sorted(ports)
|
||||
|
||||
|
||||
def probe_port(host, port, timeout=0.3):
|
||||
"""True if a TCP connect to host:port succeeds right now."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def scan_open_ports(host, ports, timeout=0.3):
|
||||
return {port for port in ports if probe_port(host, port, timeout)}
|
||||
|
||||
|
||||
def diff_new(previous, current):
|
||||
return current - previous
|
||||
|
||||
|
||||
def render_preview_html(host, port, detected_at):
|
||||
url = f"http://{host}:{port}"
|
||||
return f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>preview-sidecar: {host}:{port}</title>
|
||||
<style>
|
||||
html,body{{margin:0;height:100%;font-family:ui-monospace,Menlo,monospace}}
|
||||
.bar{{display:flex;gap:12px;align-items:center;padding:8px 12px;background:#111;color:#eee;font-size:13px}}
|
||||
.bar a{{color:#9fd;text-decoration:none}}
|
||||
iframe{{display:block;width:100%;height:calc(100% - 37px);border:0}}
|
||||
</style></head>
|
||||
<body>
|
||||
<div class="bar">
|
||||
<strong>preview-sidecar</strong>
|
||||
<span>detected {host}:{port} at {detected_at}</span>
|
||||
<a href="{url}" target="_blank" rel="noopener">open in new tab</a>
|
||||
<span>(if the frame below is blank, the dev server set X-Frame-Options/CSP — use the link)</span>
|
||||
</div>
|
||||
<iframe src="{url}" sandbox="allow-scripts allow-same-origin allow-forms"></iframe>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
def write_preview(out_dir, host, port):
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
detected_at = now_iso()
|
||||
path = out_dir / f"preview-{port}-{now_stamp()}.html"
|
||||
path.write_text(render_preview_html(host, port, detected_at))
|
||||
return path, detected_at
|
||||
|
||||
|
||||
def append_event(out_dir, event):
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
with (out_dir / "events.jsonl").open("a") as f:
|
||||
f.write(json.dumps(event) + "\n")
|
||||
|
||||
|
||||
def find_chrome(explicit=None):
|
||||
if explicit:
|
||||
return explicit if shutil.which(explicit) or os.path.exists(explicit) else None
|
||||
env = os.environ.get("CHROME_PATH")
|
||||
if env and (os.path.exists(env) or shutil.which(env)):
|
||||
return env
|
||||
for candidate in CHROME_CANDIDATES:
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def cmd_watch(args):
|
||||
host = args.host
|
||||
ports = parse_ports(args.ports, args.port_range)
|
||||
out_dir = Path(args.out)
|
||||
print(f"[preview-sidecar] watching {len(ports)} candidate ports on {host} "
|
||||
f"every {args.interval}s: {ports}")
|
||||
seen = set()
|
||||
cycles = 0
|
||||
try:
|
||||
while args.max_cycles is None or cycles < args.max_cycles:
|
||||
current = scan_open_ports(host, ports, args.timeout)
|
||||
for port in sorted(diff_new(seen, current)):
|
||||
path, detected_at = write_preview(out_dir, host, port)
|
||||
append_event(out_dir, {"event": "detected", "host": host,
|
||||
"port": port, "at": detected_at,
|
||||
"preview": str(path)})
|
||||
print(f"[preview-sidecar] detected {host}:{port} at {detected_at} "
|
||||
f"-> {path}")
|
||||
if not args.no_open:
|
||||
webbrowser.open(f"file://{path.resolve()}")
|
||||
for port in sorted(seen - current):
|
||||
print(f"[preview-sidecar] {host}:{port} no longer responding")
|
||||
seen = current
|
||||
cycles += 1
|
||||
if args.max_cycles is None or cycles < args.max_cycles:
|
||||
time.sleep(args.interval)
|
||||
except KeyboardInterrupt:
|
||||
print("[preview-sidecar] stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_preview(args):
|
||||
if not probe_port(args.host, args.port, args.timeout):
|
||||
print(f"[preview-sidecar] nothing answered {args.host}:{args.port}", file=sys.stderr)
|
||||
return 1
|
||||
path, detected_at = write_preview(args.out, args.host, args.port)
|
||||
print(f"[preview-sidecar] preview for {args.host}:{args.port} -> {path}")
|
||||
if not args.no_open:
|
||||
webbrowser.open(f"file://{path.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
def make_receipt(host, port, out_dir, chrome_path, timeout):
|
||||
if not probe_port(host, port, 0.5):
|
||||
raise RuntimeError(f"nothing answered {host}:{port}")
|
||||
chrome = find_chrome(chrome_path)
|
||||
if not chrome:
|
||||
raise RuntimeError(
|
||||
"no Chromium-family browser found; pass --chrome or set CHROME_PATH")
|
||||
out_dir = Path(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
stamp = now_stamp()
|
||||
png_path = out_dir / f"receipt-{port}-{stamp}.png"
|
||||
url = f"http://{host}:{port}"
|
||||
argv = [chrome, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
||||
"--window-size=1280,900", f"--screenshot={png_path}", url]
|
||||
result = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode != 0 or not png_path.exists():
|
||||
raise RuntimeError(
|
||||
f"screenshot failed (exit {result.returncode}): {result.stderr.strip()[:400]}")
|
||||
data = png_path.read_bytes()
|
||||
if len(data) == 0:
|
||||
raise RuntimeError("screenshot produced an empty file")
|
||||
receipt = {
|
||||
"url": url,
|
||||
"host": host,
|
||||
"port": port,
|
||||
"captured_at": now_iso(),
|
||||
"chrome": chrome,
|
||||
"png": str(png_path),
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"bytes": len(data),
|
||||
}
|
||||
json_path = out_dir / f"receipt-{port}-{stamp}.json"
|
||||
json_path.write_text(json.dumps(receipt, indent=2) + "\n")
|
||||
receipt["json"] = str(json_path)
|
||||
return receipt
|
||||
|
||||
|
||||
def cmd_screenshot(args):
|
||||
try:
|
||||
receipt = make_receipt(args.host, args.port, args.out, args.chrome, args.timeout)
|
||||
except RuntimeError as exc:
|
||||
print(f"[preview-sidecar] {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(receipt, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
p = argparse.ArgumentParser(prog="sidecar.py", description=__doc__)
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
watch = sub.add_parser("watch", help="poll for newly bound local ports and auto-preview them")
|
||||
watch.add_argument("--host", default="127.0.0.1")
|
||||
watch.add_argument("--ports", default=None, help="comma-separated port list")
|
||||
watch.add_argument("--port-range", default=None, help="e.g. 3000-3010")
|
||||
watch.add_argument("--interval", type=float, default=2.0)
|
||||
watch.add_argument("--timeout", type=float, default=0.3)
|
||||
watch.add_argument("--max-cycles", type=int, default=None, help="stop after N poll cycles (default: run until Ctrl+C)")
|
||||
watch.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
watch.add_argument("--no-open", action="store_true", help="don't launch a browser tab")
|
||||
watch.set_defaults(func=cmd_watch)
|
||||
|
||||
preview = sub.add_parser("preview", help="manually open a preview pane for one port right now")
|
||||
preview.add_argument("--host", default="127.0.0.1")
|
||||
preview.add_argument("--port", type=int, required=True)
|
||||
preview.add_argument("--timeout", type=float, default=0.3)
|
||||
preview.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
preview.add_argument("--no-open", action="store_true")
|
||||
preview.set_defaults(func=cmd_preview)
|
||||
|
||||
screenshot = sub.add_parser("screenshot", help="capture a timestamped screenshot receipt of one port")
|
||||
screenshot.add_argument("--host", default="127.0.0.1")
|
||||
screenshot.add_argument("--port", type=int, required=True)
|
||||
screenshot.add_argument("--out", default=str(Path(tempfile.gettempdir()) / "preview-sidecar"))
|
||||
screenshot.add_argument("--chrome", default=None, help="path to a Chromium-family browser binary")
|
||||
screenshot.add_argument("--timeout", type=float, default=20.0)
|
||||
screenshot.set_defaults(func=cmd_screenshot)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
return args.func(args)
|
||||
except ValueError as exc:
|
||||
print(f"[preview-sidecar] {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import sidecar
|
||||
|
||||
|
||||
class ParsePorts(unittest.TestCase):
|
||||
def test_default_when_nothing_given(self):
|
||||
self.assertEqual(sidecar.parse_ports(None, None), sorted(sidecar.DEFAULT_PORTS))
|
||||
|
||||
def test_explicit_list(self):
|
||||
self.assertEqual(sidecar.parse_ports("3000,3000,5173", None), [3000, 5173])
|
||||
|
||||
def test_range(self):
|
||||
self.assertEqual(sidecar.parse_ports(None, "3000-3003"), [3000, 3001, 3002, 3003])
|
||||
|
||||
def test_range_too_wide_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.parse_ports(None, f"1-{sidecar.MAX_PORTS + 50}")
|
||||
|
||||
def test_bad_port_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
sidecar.parse_ports("70000", None)
|
||||
|
||||
|
||||
class ProbeAndDiff(unittest.TestCase):
|
||||
def test_probe_detects_a_real_listener(self):
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
self.assertTrue(sidecar.probe_port("127.0.0.1", port))
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
def test_probe_false_for_closed_port(self):
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
port = srv.getsockname()[1]
|
||||
srv.close()
|
||||
self.assertFalse(sidecar.probe_port("127.0.0.1", port, timeout=0.2))
|
||||
|
||||
def test_diff_new_only_reports_additions(self):
|
||||
self.assertEqual(sidecar.diff_new({3000}, {3000, 5173}), {5173})
|
||||
self.assertEqual(sidecar.diff_new({3000, 5173}, {5173}), set())
|
||||
|
||||
|
||||
class PreviewHtml(unittest.TestCase):
|
||||
def test_render_contains_iframe_and_url(self):
|
||||
html = sidecar.render_preview_html("127.0.0.1", 5173, "2026-01-01T00:00:00Z")
|
||||
self.assertIn("http://127.0.0.1:5173", html)
|
||||
self.assertIn("<iframe", html)
|
||||
self.assertIn("open in new tab", html)
|
||||
|
||||
def test_write_preview_creates_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path, detected_at = sidecar.write_preview(tmp, "127.0.0.1", 4200)
|
||||
self.assertTrue(path.exists())
|
||||
self.assertIn("4200", path.name)
|
||||
self.assertIn("http://127.0.0.1:4200", path.read_text())
|
||||
self.assertRegex(detected_at, r"^\d{4}-\d{2}-\d{2}T")
|
||||
|
||||
|
||||
class FindChrome(unittest.TestCase):
|
||||
def test_explicit_path_wins_when_present(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
fake = Path(tmp) / "chrome-bin"
|
||||
fake.write_text("")
|
||||
self.assertEqual(sidecar.find_chrome(str(fake)), str(fake))
|
||||
|
||||
def test_explicit_missing_path_returns_none(self):
|
||||
self.assertIsNone(sidecar.find_chrome("/definitely/not/a/real/chrome/binary"))
|
||||
|
||||
def test_env_var_used_when_set(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
fake = Path(tmp) / "chrome-env"
|
||||
fake.write_text("")
|
||||
with mock.patch.dict("os.environ", {"CHROME_PATH": str(fake)}):
|
||||
self.assertEqual(sidecar.find_chrome(), str(fake))
|
||||
|
||||
def test_none_when_nothing_found(self):
|
||||
with mock.patch.object(sidecar, "CHROME_CANDIDATES", ["/nope/not/here"]), \
|
||||
mock.patch.dict("os.environ", {}, clear=True):
|
||||
self.assertIsNone(sidecar.find_chrome())
|
||||
|
||||
|
||||
class MakeReceiptErrors(unittest.TestCase):
|
||||
def test_raises_when_port_not_open(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaises(RuntimeError):
|
||||
sidecar.make_receipt("127.0.0.1", 1, tmp, None, 5)
|
||||
|
||||
def test_raises_when_no_chrome_found(self):
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(1)
|
||||
port = srv.getsockname()[1]
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch.object(sidecar, "find_chrome", return_value=None):
|
||||
with self.assertRaises(RuntimeError):
|
||||
sidecar.make_receipt("127.0.0.1", port, tmp, None, 5)
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user