From c653e65d5bc9c5976d4118534e2e3c3f269d9232 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 16:08:32 -0500 Subject: [PATCH] diagnostic capture feature --- app/api/captures.py | 59 ++++++++++++++++++++++++ app/api/meta.py | 19 ++++++++ app/api/routes.py | 39 ++++++++++++++++ static/app.js | 96 +++++++++++++++++++++++++++++++++++++--- static/chart.js | 2 + static/index.html | 13 ++++-- static/style.css | 4 +- tests/e2e/chart.test.mjs | 65 +++++++++++++++++++++++++++ tests/test_auth.py | 39 ++++++++++++++++ 9 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 app/api/captures.py diff --git a/app/api/captures.py b/app/api/captures.py new file mode 100644 index 0000000..158a2dd --- /dev/null +++ b/app/api/captures.py @@ -0,0 +1,59 @@ +import json +import os +import re +import secrets +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path + +CAPTURE_DIR = Path(tempfile.gettempdir()) / "chart-captures" +CAPTURE_TTL_SECONDS = 24 * 60 * 60 +CAPTURE_LIMIT = 50 +CAPTURE_MAX_BYTES = 10 * 1024 * 1024 +CAPTURE_ID = re.compile(r"^c-[A-Za-z0-9_-]{12}$") + + +def _remove(capture_id: str) -> None: + for suffix in (".png", ".json"): + (CAPTURE_DIR / f"{capture_id}{suffix}").unlink(missing_ok=True) + + +def cleanup_captures(now: float | None = None) -> None: + current = time.time() if now is None else now + images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime) + for image in images: + if current - image.stat().st_mtime > CAPTURE_TTL_SECONDS: + _remove(image.stem) + images = sorted(CAPTURE_DIR.glob("c-*.png"), key=lambda path: path.stat().st_mtime) + for image in images[: max(0, len(images) - CAPTURE_LIMIT + 1)]: + _remove(image.stem) + + +def save_capture(image: bytes, metadata: dict) -> tuple[str, dict]: + if not image.startswith(b"\x89PNG\r\n\x1a\n"): + raise ValueError("Capture is not a PNG") + if len(image) > CAPTURE_MAX_BYTES: + raise ValueError("Capture exceeds 10 MB") + CAPTURE_DIR.mkdir(parents=True, exist_ok=True) + cleanup_captures() + capture_id = f"c-{secrets.token_urlsafe(9)}" + details = { + **metadata, + "id": capture_id, + "received_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "commit": os.environ.get("SOURCE_COMMIT", "dev"), + "bytes": len(image), + } + image_path = CAPTURE_DIR / f"{capture_id}.png" + metadata_path = CAPTURE_DIR / f"{capture_id}.json" + image_path.write_bytes(image) + metadata_path.write_text(json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return capture_id, details + + +def capture_path(capture_id: str, suffix: str) -> Path | None: + if not CAPTURE_ID.fullmatch(capture_id): + return None + path = CAPTURE_DIR / f"{capture_id}{suffix}" + return path if path.is_file() else None diff --git a/app/api/meta.py b/app/api/meta.py index 424c9b3..12906df 100644 --- a/app/api/meta.py +++ b/app/api/meta.py @@ -5,9 +5,11 @@ the browser needs /api/login before it has a session, so these routes stay outside the protected API router. """ import os +import json from datetime import datetime, timezone from fastapi import APIRouter, HTTPException, Request, Response, status +from fastapi.responses import FileResponse from pydantic import BaseModel from app.api.deps import ( @@ -18,6 +20,7 @@ from app.api.deps import ( password_matches, token_matches, ) +from app.api.captures import capture_path router = APIRouter(prefix="/api") @@ -40,6 +43,22 @@ def version(): return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT} +@router.get("/debug/captures/{capture_id}") +def get_debug_capture(capture_id: str): + path = capture_path(capture_id, ".png") + if path is None: + raise HTTPException(404, "Capture not found") + return FileResponse(path, media_type="image/png") + + +@router.get("/debug/captures/{capture_id}/meta") +def get_debug_capture_metadata(capture_id: str): + path = capture_path(capture_id, ".json") + if path is None: + raise HTTPException(404, "Capture not found") + return json.loads(path.read_text(encoding="utf-8")) + + @router.post("/login", status_code=status.HTTP_204_NO_CONTENT) def login(credentials: LoginRequest, request: Request, response: Response): presented_token = request.headers.get("x-chart-token", "") diff --git a/app/api/routes.py b/app/api/routes.py index 2298201..389d73a 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,3 +1,5 @@ +import base64 +import json import logging import time import uuid @@ -9,6 +11,7 @@ from app.bars.models import Timeframe from app.analysis.levels import Side from app.analysis.manual_lines import ManualLine from app.api.deps import require_token +from app.api.captures import CAPTURE_MAX_BYTES, save_capture # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and # /version live in app.api.meta and stay open on purpose. @@ -17,6 +20,42 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) +@router.post("/debug/captures", status_code=201) +async def create_debug_capture(request: Request): + try: + content_length = int(request.headers.get("content-length", "0") or 0) + except ValueError: + raise HTTPException(400, "Invalid Content-Length") from None + if content_length > CAPTURE_MAX_BYTES: + raise HTTPException(413, "Capture exceeds 10 MB") + if request.headers.get("content-type", "").split(";", 1)[0] != "image/png": + raise HTTPException(415, "Diagnostic capture must be image/png") + try: + encoded = request.headers.get("x-capture-metadata", "") + metadata = json.loads(base64.b64decode(encoded, validate=True)) if encoded else {} + if not isinstance(metadata, dict): + raise ValueError + except (ValueError, json.JSONDecodeError): + raise HTTPException(400, "Invalid capture metadata") from None + image = await request.body() + try: + capture_id, details = save_capture(image, metadata) + except ValueError as exc: + raise HTTPException(400, str(exc)) from exc + logger.warning( + "CAPTUREDBG id=%s tf=%s viewport=%sx%s", + capture_id, + details.get("timeframe"), + details.get("viewport_width"), + details.get("viewport_height"), + ) + return { + "id": capture_id, + "url": f"/api/debug/captures/{capture_id}", + "metadata_url": f"/api/debug/captures/{capture_id}/meta", + } + + class LineCreate(BaseModel): tf: Timeframe side: Side diff --git a/static/app.js b/static/app.js index 1f3a092..884dbed 100644 --- a/static/app.js +++ b/static/app.js @@ -110,8 +110,8 @@ createApp({ const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); const clusters = ref([]); - const alerts = ref([]); - let alertSequence = 0; + const events = ref([]); + let eventSequence = 0; // Which tool the next chart gesture creates. null = pan/select as normal. const armedTool = ref(null); const drawName = ref(''); @@ -125,6 +125,8 @@ createApp({ const comments = ref([]); const drawingFilter = ref(''); const drawingKind = ref('all'); + const diagnosticMode = ref(ConfluenceChart.diagnosticMode()); + const captureBusy = ref(false); const snap = ref(true); const selectedLine = ref(null); const selectedLines = ref([]); @@ -191,6 +193,13 @@ createApp({ }); }); + function addEvent(message, kind = 'info', url = null) { + events.value = [ + { key: ++eventSequence, at: new Date().toLocaleTimeString(), message, kind, url }, + ...events.value, + ].slice(0, 20); + } + async function refreshStatus() { const response = await apiFetch('/api/status'); if (response.ok) status.value = await response.json(); @@ -238,10 +247,7 @@ createApp({ } else if (message.type === 'alert') { // Keyed on a counter, not the timestamp: two alerts inside the same // second would collide and Vue would reuse the wrong row. - alerts.value = [ - { key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message }, - ...alerts.value, - ].slice(0, 20); + addEvent(message.message, 'alert'); playAlert(); } }; @@ -281,6 +287,82 @@ createApp({ oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; } + function encodeCaptureMetadata(metadata) { + const bytes = new TextEncoder().encode(JSON.stringify(metadata)); + return btoa([...bytes].map(byte => String.fromCharCode(byte)).join('')); + } + + async function captureDiagnostic() { + if (captureBusy.value) return; + if (!navigator.mediaDevices?.getDisplayMedia) { + addEvent('CAPTURE UNAVAILABLE · use HTTPS and a supported browser', 'capture-error'); + return; + } + captureBusy.value = true; + let stream = null; + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' }, + audio: false, + preferCurrentTab: true, + selfBrowserSurface: 'include', + }); + const video = document.createElement('video'); + video.muted = true; + await new Promise((resolve, reject) => { + video.onloadedmetadata = resolve; + video.onerror = reject; + video.srcObject = stream; + }); + await video.play(); + await new Promise(resolve => { + if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(() => resolve()); + else requestAnimationFrame(() => resolve()); + }); + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + const image = await new Promise(resolve => canvas.toBlob(resolve, 'image/png')); + if (!image) throw new Error('Browser did not produce a PNG'); + const version = await fetch('/api/version').then(response => response.json()).catch(() => ({})); + const bars = chartApi?.bars || []; + const selected = levels.value.find(level => level.id === selectedLine.value) || null; + const metadata = { + url: location.href, + timeframe: timeframe.value, + viewport_width: window.innerWidth, + viewport_height: window.innerHeight, + device_pixel_ratio: window.devicePixelRatio, + image_width: canvas.width, + image_height: canvas.height, + first_bar_t: bars[0]?.t ?? null, + last_bar_t: bars[bars.length - 1]?.t ?? null, + bars_held: bars.length, + selected_drawing: selected, + latest_snap: chartApi?.lastSnapDiagnostic ?? null, + commit: version.commit ?? null, + }; + const response = await apiFetch('/api/debug/captures', { + method: 'POST', + headers: { + 'Content-Type': 'image/png', + 'X-Capture-Metadata': encodeCaptureMetadata(metadata), + }, + body: image, + }); + if (!response.ok) throw new Error(`upload returned HTTP ${response.status}`); + const saved = await response.json(); + addEvent(`CAPTURE ${saved.id} · ${timeframe.value}`, 'capture', saved.url); + } catch (error) { + const cancelled = error?.name === 'AbortError' || error?.name === 'NotAllowedError'; + addEvent(cancelled ? 'CAPTURE CANCELLED' : `CAPTURE FAILED · ${error.message}`, 'capture-error'); + } finally { + stream?.getTracks().forEach(track => track.stop()); + captureBusy.value = false; + } + } + function armTool(tool) { armedTool.value = armedTool.value === tool ? null : tool; selectedLine.value = null; @@ -677,6 +759,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; + return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index 672b7e3..599a4a3 100644 --- a/static/chart.js +++ b/static/chart.js @@ -56,6 +56,7 @@ class ConfluenceChart { this.contextLabelFrame = null; this.onCommentToggle = null; this.onCommentMove = null; + this.lastSnapDiagnostic = null; } static TICK = 0.25; @@ -899,6 +900,7 @@ class ConfluenceChart { ? 'coordinateToTime returned null' : `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`, }; + this.lastSnapDiagnostic = body; fetch('/api/debug/snap', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/static/index.html b/static/index.html index 0a55308..61c2408 100644 --- a/static/index.html +++ b/static/index.html @@ -32,6 +32,9 @@ Drag on the chart from one point to the other Click or drag on the chart to set the price Line selected — Delete removes it + UPDATED {{ dataUpdatedAt }} @@ -224,9 +227,13 @@ diff --git a/static/style.css b/static/style.css index da4c210..d27800c 100644 --- a/static/style.css +++ b/static/style.css @@ -15,7 +15,7 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } #chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden] { display:none; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; pointer-events:none; }.chart-tooltip[hidden] { display:none; } .chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; } -.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; } +.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } .sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; } @@ -53,7 +53,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; } .arm-hint { color:var(--accent); } .optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } -.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; } +.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }.alert-entry a { color:var(--accent); text-decoration:none; }.alert-entry.capture { border-left:2px solid var(--accent); }.alert-entry.capture-error { border-left:2px solid var(--red); } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } /* --- chart comments ---------------------------------------------------- */ .chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; } diff --git a/tests/e2e/chart.test.mjs b/tests/e2e/chart.test.mjs index 49d6f8f..fc44788 100644 --- a/tests/e2e/chart.test.mjs +++ b/tests/e2e/chart.test.mjs @@ -71,6 +71,71 @@ test('an existing browser token is migrated once and removed from local storage' } }); +test('diagnostic capture uploads a PNG and adds its capability ID to Events', + { timeout: 180000 }, async () => { + const { browser, page } = await launch(); + let uploaded = null; + try { + await page.addInitScript(() => localStorage.setItem('chart-diag', '1')); + await page.route('**/api/debug/captures', async route => { + uploaded = { + contentType: route.request().headers()['content-type'], + metadata: JSON.parse(Buffer.from( + route.request().headers()['x-capture-metadata'], 'base64').toString()), + }; + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ + id: 'c-TESTCAPTURE1', + url: '/api/debug/captures/c-TESTCAPTURE1', + }), + }); + }); + await openChart(page); + await page.evaluate(() => { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getDisplayMedia: async () => ({ getTracks: () => [{ stop() {} }] }) }, + }); + Object.defineProperty(HTMLMediaElement.prototype, 'srcObject', { + configurable: true, + set() { setTimeout(() => this.onloadedmetadata?.(), 0); }, + }); + Object.defineProperty(HTMLVideoElement.prototype, 'videoWidth', { + configurable: true, get: () => 800, + }); + Object.defineProperty(HTMLVideoElement.prototype, 'videoHeight', { + configurable: true, get: () => 600, + }); + HTMLMediaElement.prototype.play = async function play() {}; + HTMLVideoElement.prototype.requestVideoFrameCallback = function callback(done) { done(); }; + HTMLCanvasElement.prototype.getContext = () => ({ drawImage() {} }); + HTMLCanvasElement.prototype.toBlob = function toBlob(done) { + done(new Blob([new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])], { + type: 'image/png', + })); + }; + }); + + await page.locator('.diag-capture').click(); + await page.waitForFunction(() => + [...document.querySelectorAll('.alert-entry')] + .some(entry => entry.textContent.includes('c-TESTCAPTURE1'))); + + assert.equal(uploaded.contentType, 'image/png'); + assert.equal(uploaded.metadata.timeframe, '1m'); + assert.equal(uploaded.metadata.image_width, 800); + assert.equal(uploaded.metadata.image_height, 600); + const event = page.locator('.alert-entry.capture a'); + assert.match(await event.textContent(), /CAPTURE c-TESTCAPTURE1 · 1m/); + assert.match(await event.getAttribute('href'), /\/api\/debug\/captures\/c-TESTCAPTURE1$/); + assertNoPageErrors(page, assert); + } finally { + await browser.close(); + } + }); + test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => { await withChart(async page => { const state = await chartState(page); diff --git a/tests/test_auth.py b/tests/test_auth.py index 3b22971..ee59252 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,3 +1,6 @@ +import base64 +import json + import pytest import jwt from fastapi import FastAPI @@ -5,6 +8,7 @@ from fastapi.testclient import TestClient from starlette.websockets import WebSocketDisconnect from app.api.meta import router as meta_router +from app.api import captures from app.api.deps import create_session, session_matches, session_secret from app.api.routes import router as api_router from app.api.schwab_auth import router as schwab_auth_router @@ -170,6 +174,41 @@ def test_writes_are_protected(client): assert client("s3cret").post("/api/lines", json=payload).status_code == 401 +def test_diagnostic_capture_is_authenticated_and_retrievable_by_capability_id( + client, tmp_path, monkeypatch +): + monkeypatch.setattr(captures, "CAPTURE_DIR", tmp_path / "captures") + probe = client("s3cret") + image = b"\x89PNG\r\n\x1a\ntrimmed-test-image" + metadata = base64.b64encode( + json.dumps({"timeframe": "30m", "viewport_width": 1440}).encode() + ).decode() + + assert probe.post( + "/api/debug/captures", + content=image, + headers={"Content-Type": "image/png", "X-Capture-Metadata": metadata}, + ).status_code == 401 + response = probe.post( + "/api/debug/captures", + content=image, + headers={ + "X-Chart-Token": "s3cret", + "Content-Type": "image/png", + "X-Capture-Metadata": metadata, + }, + ) + + assert response.status_code == 201 + saved = response.json() + assert saved["id"].startswith("c-") + assert probe.get(saved["url"]).content == image + details = probe.get(saved["metadata_url"]).json() + assert details["id"] == saved["id"] + assert details["timeframe"] == "30m" + assert details["viewport_width"] == 1440 + + @pytest.mark.parametrize("path", ["/api/health", "/api/version"]) def test_meta_endpoints_stay_open(client, path): """bin/wait-deploy polls /api/version without carrying the token."""