diagnostic capture feature
This commit is contained in:
parent
ff1b9982d1
commit
c653e65d5b
9 changed files with 324 additions and 12 deletions
59
app/api/captures.py
Normal file
59
app/api/captures.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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", "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@
|
|||
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
|
||||
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
|
||||
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
|
||||
<button v-if="diagnosticMode" class="diag-capture" :disabled="captureBusy" @click="captureDiagnostic">
|
||||
{{ captureBusy ? 'CAPTURING…' : 'CAPTURE DIAGNOSTIC' }}
|
||||
</button>
|
||||
<span class="data-freshness">UPDATED <b>{{ dataUpdatedAt }}</b></span>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -224,9 +227,13 @@
|
|||
</div>
|
||||
</details>
|
||||
<details class="sidebar-section">
|
||||
<summary>Alert log ({{ alerts.length }})</summary>
|
||||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
||||
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
||||
<summary>Events ({{ events.length }})</summary>
|
||||
<div v-if="!events.length" class="empty">No events yet.</div>
|
||||
<div v-for="event in events" :key="event.key" class="alert-entry" :class="event.kind">
|
||||
<time>{{ event.at }}</time>
|
||||
<a v-if="event.url" :href="event.url" target="_blank" rel="noopener">{{ event.message }}</a>
|
||||
<template v-else>{{ event.message }}</template>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue