diff --git a/.github/workflows/plugin-api-v3.yml b/.github/workflows/plugin-api-v3.yml index 4b7e121..1659e83 100644 --- a/.github/workflows/plugin-api-v3.yml +++ b/.github/workflows/plugin-api-v3.yml @@ -36,7 +36,7 @@ jobs: - uses: actions/checkout@v4 with: repository: kachofugetsu09/akashic-agent - ref: 39cbdcefc155aaf6c41deafd7754a37e6126c23c + ref: 69a9616f6f48f19dc109a6b3eef8fc1000825829 path: .akashic-core - uses: actions/setup-python@v5 with: diff --git a/dashboard.py b/dashboard.py index aa85837..d3596b4 100644 --- a/dashboard.py +++ b/dashboard.py @@ -2,10 +2,10 @@ from collections.abc import Mapping, Sequence from typing import Any +from urllib.parse import urlsplit import requests from fastapi import FastAPI, HTTPException -from fastapi.responses import RedirectResponse from agent.plugin_composition import DashboardContext @@ -32,8 +32,8 @@ def refresh() -> dict[str, str]: return {"status": "refreshing"} @app.get("/api/dashboard/fitbit/auth/start") - def auth_start() -> RedirectResponse: - return RedirectResponse(f"{_MONITOR_URL}/auth/start") + def auth_start() -> dict[str, str]: + return {"url": _monitor_authorization_url()} def _monitor_json(path: str) -> Mapping[str, object]: @@ -56,6 +56,32 @@ def _monitor_payload(path: str) -> object: return payload +def _monitor_authorization_url() -> str: + """Read the monitor-generated Fitbit authorization redirect as a DTO.""" + + try: + response = requests.get( + f"{_MONITOR_URL}/auth/start", + timeout=8, + allow_redirects=False, + ) + response.raise_for_status() + except requests.RequestException as error: + raise HTTPException(status_code=502, detail="Fitbit monitor 不可用: /auth/start") from error + + authorization_url = response.headers.get("location") + parsed = urlsplit(authorization_url or "") + if ( + not response.is_redirect + or not isinstance(authorization_url, str) + or parsed.scheme != "https" + or parsed.netloc != "www.fitbit.com" + or parsed.path != "/oauth2/authorize" + ): + raise HTTPException(status_code=502, detail="Fitbit monitor 返回无效授权地址") + return authorization_url + + def _project_dashboard_snapshot(payload: Mapping[str, object]) -> dict[str, object]: """Validate and project the monitor-owned compact snapshot.""" diff --git a/package.json b/package.json index 8dffd61..5bb3045 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "test:mobile": "node --test tests/test_mobile_panel.mjs", - "test:dashboard": "node --test tests/test_dashboard_panel.mjs", - "test": "node --test tests/test_mobile_panel.mjs tests/test_dashboard_panel.mjs" + "test:dashboard": "node --test tests/test_web_module.mjs", + "test": "node --test tests/test_mobile_panel.mjs tests/test_web_module.mjs" }, "devDependencies": { "linkedom": "^0.18.13" diff --git a/plugin.py b/plugin.py index 6f37ec3..30b85e3 100644 --- a/plugin.py +++ b/plugin.py @@ -51,6 +51,12 @@ class FitbitConfig(BaseModel): UI_SLOTS, ) dashboard_module = "dashboard.py" +web_module = "web_module.js" +web_requires = ("workbench.panels.v1",) +web_provides = () +web_contract_digests = { + "workbench.panels.v1": "724b282c22c4b3f3a36967ab664c4dfd8bce4257665f99459000306938caf527", +} async def apply(ctx: Context, config: FitbitConfig) -> None: diff --git a/scripts/preview_dashboard.py b/scripts/preview_dashboard.py index c789921..2cc6454 100644 --- a/scripts/preview_dashboard.py +++ b/scripts/preview_dashboard.py @@ -34,8 +34,8 @@ def run_preview(agent_root: Path, plugin_root: Path, host: str, port: int) -> No plugin_root = plugin_root.resolve(strict=True) if not (agent_root / "bootstrap" / "dashboard_api.py").is_file(): raise FileNotFoundError(f"Akashic Agent Dashboard 不存在: {agent_root}") - if not (plugin_root / "dashboard_panel.js").is_file(): - raise FileNotFoundError(f"Fitbit Dashboard 面板不存在: {plugin_root}") + if not (plugin_root / "web_module.js").is_file(): + raise FileNotFoundError(f"Fitbit Workbench 面板不存在: {plugin_root}") # 2. Project the plugin into an isolated HOME and reuse the real Dashboard host. with tempfile.TemporaryDirectory(prefix="fitbit-dashboard-preview-") as temp: diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index ad51565..182b97c 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -125,6 +125,28 @@ def monitor_json(path: str): assert calls == ["/api/dashboard/snapshot"] +def test_monitor_authorization_redirect_becomes_a_plugin_owned_dto( + monkeypatch: pytest.MonkeyPatch, +) -> None: + authorization_url = "https://www.fitbit.com/oauth2/authorize?client_id=fitbit" + calls: list[tuple[str, int, bool]] = [] + + class Response: + is_redirect = True + headers = {"location": authorization_url} + + def raise_for_status(self) -> None: + return None + + def get(url: str, *, timeout: int, allow_redirects: bool) -> Response: + calls.append((url, timeout, allow_redirects)) + return Response() + + monkeypatch.setattr(dashboard.requests, "get", get) + assert dashboard._monitor_authorization_url() == authorization_url + assert calls == [("http://127.0.0.1:18765/auth/start", 8, False)] + + def test_compact_snapshot_boundary_rejects_missing_prediction_events() -> None: with pytest.raises(HTTPException, match="prediction_events 必须是数组"): dashboard._project_dashboard_snapshot( diff --git a/tests/test_dashboard_panel.mjs b/tests/test_dashboard_panel.mjs deleted file mode 100644 index a53c014..0000000 --- a/tests/test_dashboard_panel.mjs +++ /dev/null @@ -1,128 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { parseHTML } from "linkedom"; - - -const overview = { - last_updated: "13:27:01", - stale: false, - current: { - heart_rate: 72, - spo2: 97.4, - steps: 4823, - sleep_state: "awake", - sleep_reason: "高概率清醒", - sleep_prob: 0.18, - }, - freshness: { data_lag_min: 3, spo2_lag_min: 12 }, - signals: { prob_source: "ml", hr_avg: 71.5 }, - sleep_24h: [ - { range: "23:00-07:00", state: "sleeping" }, - { range: "07:00-13:27", state: "awake" }, - ], - prediction_events: [ - { - time: "2026-08-01 13:27:00", - source: "ml", - sleep_probability: 0.18, - final_state: "awake", - reason: "Viterbi 判定清醒", - changed: false, - }, - { - time: "2026-08-01 12:27:00", - source: "ml", - sleep_probability: 0.75, - final_state: "sleeping", - reason: "Viterbi 判定睡眠", - changed: true, - }, - { - time: "2026-08-01 11:27:00", - source: "ml", - sleep_probability: 0.12, - final_state: "awake", - reason: "Viterbi 判定清醒", - changed: false, - }, - ], - heart_rate_series: [ - { time: "13:25:00", value: 69 }, - { time: "13:26:00", value: 72 }, - ], - steps_series: [], -}; - - -test("Dashboard panel renders the current monitor snapshot without a table", async () => { - const { window } = parseHTML("
"); - const registered = []; - window.api = async () => overview; - window.setInterval = setInterval; - window.clearInterval = clearInterval; - window.setTimeout = setTimeout; - window.AkashicDashboard = { registerPlugin: (plugin) => registered.push(plugin) }; - globalThis.window = window; - globalThis.document = window.document; - - await import(`../dashboard_panel.js?test=${Date.now()}`); - assert.equal(registered.length, 1); - assert.equal(registered[0].layout, "workbench"); - assert.equal(registered[0].getCount(), 1); - - const host = window.document.querySelector("#host"); - registered[0].renderMain(host, {}); - await new Promise((resolve) => setTimeout(resolve, 0)); - - assert.equal(host.querySelector("[data-fitbit-state]").textContent, "清醒"); - assert.equal(host.querySelector("[data-fitbit-heart]").textContent, "72"); - assert.equal(host.querySelector("[data-fitbit-oxygen]").textContent, "97.4"); - assert.equal(host.querySelector("[data-fitbit-steps]").textContent, "4,823"); - assert.equal(host.querySelectorAll("table").length, 0); - assert.equal(host.querySelectorAll("[data-fitbit-timeline] > span").length, 2); - assert.equal(host.querySelectorAll("[data-fitbit-prediction-events] > li").length, 3); - assert.match(host.querySelector("[data-fitbit-prediction-events]").textContent, /ML 模型 · 睡眠概率 18%/); - assert.match(host.querySelector("[data-fitbit-prediction-events]").textContent, /最终状态清醒/); - assert.match(host.querySelector("[data-fitbit-prediction-line]").getAttribute("d"), /^M.*L.*L/); - assert.equal(host.querySelectorAll("[data-fitbit-prediction-markers] > i").length, 2); - assert.equal(host.querySelector("[data-fitbit-prediction-start]").textContent, "08-01 11:27"); - assert.equal(host.querySelector("[data-fitbit-prediction-end]").textContent, "现在 · 13:27"); - assert.equal(host.querySelector("[data-fitbit-prediction-window]").textContent, "最近 24 小时 · 3 次判断"); - assert.equal(host.querySelector(".fitbit-dashboard__eyebrow"), null); - assert.match(host.querySelector(".fitbit-dashboard__subtitle").textContent, /^展示 Fitbit 观测/); - assert.match(host.querySelector("[data-fitbit-heart-path]").getAttribute("d"), /^M/); - - host.__fitbitDashboardDispose(); -}); - - -test("Dashboard panel coalesces overlapping initial and focus loads", async () => { - const { window } = parseHTML(""); - const registered = []; - let requests = 0; - let resolveRequest; - window.api = () => { - requests += 1; - return new Promise((resolve) => { - resolveRequest = resolve; - }); - }; - window.setInterval = setInterval; - window.clearInterval = clearInterval; - window.setTimeout = setTimeout; - window.AkashicDashboard = { registerPlugin: (plugin) => registered.push(plugin) }; - globalThis.window = window; - globalThis.document = window.document; - - await import(`../dashboard_panel.js?coalesce=${Date.now()}`); - const host = window.document.querySelector("#host"); - registered[0].renderMain(host, {}); - window.dispatchEvent(new window.Event("focus")); - window.dispatchEvent(new window.Event("focus")); - - assert.equal(requests, 1); - resolveRequest(overview); - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.equal(host.querySelector("[data-fitbit-state]").textContent, "清醒"); - host.__fitbitDashboardDispose(); -}); diff --git a/tests/test_web_module.mjs b/tests/test_web_module.mjs new file mode 100644 index 0000000..7d62a59 --- /dev/null +++ b/tests/test_web_module.mjs @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setImmediate } from "node:timers/promises"; +import { parseHTML } from "linkedom"; + +import { activate } from "../web_module.js"; + + +const overview = { + last_updated: "13:27:01", + stale: false, + current: { + heart_rate: 72, + spo2: 97.4, + steps: 4823, + sleep_state: "awake", + sleep_reason: "高概率清醒", + sleep_prob: 0.18, + }, + freshness: { data_lag_min: 3, spo2_lag_min: 12 }, + signals: { prob_source: "ml", hr_avg: 71.5 }, + sleep_24h: [ + { range: "23:00-07:00", state: "sleeping" }, + { range: "07:00-13:27", state: "awake" }, + ], + prediction_events: [ + { + time: "2026-08-01 13:27:00", + source: "ml", + sleep_probability: 0.18, + final_state: "awake", + reason: "Viterbi 判定清醒", + changed: false, + }, + { + time: "2026-08-01 12:27:00", + source: "ml", + sleep_probability: 0.75, + final_state: "sleeping", + reason: "Viterbi 判定睡眠", + changed: true, + }, + { + time: "2026-08-01 11:27:00", + source: "ml", + sleep_probability: 0.12, + final_state: "awake", + reason: "Viterbi 判定清醒", + changed: false, + }, + ], + heart_rate_series: [ + { time: "13:25:00", value: 69 }, + { time: "13:26:00", value: 72 }, + ], + steps_series: [], +}; + + +function response(payload, status = 200) { + return { ok: status >= 200 && status < 300, status, json: async () => payload }; +} + + +async function settle() { + await setImmediate(); +} + +function installPanel(http) { + const { window } = parseHTML(""); + window.setInterval = setInterval; + window.clearInterval = clearInterval; + window.setTimeout = setTimeout; + window.clearTimeout = clearTimeout; + globalThis.window = window; + globalThis.document = window.document; + let panel; + let released = false; + const release = activate({ + ui: { + inject(contract, callback) { + assert.equal(contract, "workbench.panels.v1"); + return callback({ + register(definition) { + panel = definition; + return () => { released = true; }; + }, + }); + }, + }, + http: { request: http }, + }); + return { window, host: window.document.querySelector("#host"), panel, release, wasReleased: () => released }; +} + + +test("Workbench panel renders the current monitor snapshot through ctx.http", async () => { + const fixture = installPanel(async (path) => { + assert.equal(path, "/api/dashboard/fitbit/overview"); + return response(overview); + }); + + assert.equal(fixture.panel.id, "fitbit"); + const dispose = fixture.panel.render(fixture.host); + await settle(); + + assert.equal(fixture.host.querySelector("[data-fitbit-state]").textContent, "清醒"); + assert.equal(fixture.host.querySelector("[data-fitbit-heart]").textContent, "72"); + assert.equal(fixture.host.querySelector("[data-fitbit-oxygen]").textContent, "97.4"); + assert.equal(fixture.host.querySelector("[data-fitbit-steps]").textContent, "4,823"); + assert.equal(fixture.host.querySelectorAll("table").length, 0); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-timeline] > span").length, 2); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-prediction-events] > li").length, 3); + assert.match(fixture.host.querySelector("[data-fitbit-prediction-line]").getAttribute("d"), /^M.*L.*L/); + assert.equal(fixture.host.querySelectorAll("[data-fitbit-prediction-markers] > i").length, 2); + + dispose(); + fixture.release(); + assert.equal(fixture.host.childNodes.length, 0); + assert.equal(fixture.wasReleased(), true); +}); + + +test("Workbench panel coalesces focus loads and disposes its request and interval", () => { + let requests = 0; + let signal; + const fixture = installPanel((_path, init) => { + requests += 1; + signal = init.signal; + return new Promise(() => {}); + }); + let cleared = null; + fixture.window.setInterval = () => 17; + fixture.window.clearInterval = (timer) => { cleared = timer; }; + + const dispose = fixture.panel.render(fixture.host); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + + assert.equal(requests, 1); + dispose(); + fixture.window.dispatchEvent(new fixture.window.Event("focus")); + assert.equal(requests, 1); + assert.equal(cleared, 17); + assert.equal(signal.aborted, true); +}); + + +test("Workbench panel clears its delayed refresh callback on dispose", async () => { + const fixture = installPanel(async () => response(overview)); + const timeouts = []; + let cleared = null; + fixture.window.setTimeout = (callback, delay) => { + timeouts.push({ callback, delay }); + return 23; + }; + fixture.window.clearTimeout = (timer) => { cleared = timer; }; + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-refresh]").click(); + await settle(); + + assert.equal(timeouts.length, 1); + assert.equal(timeouts[0].delay, 900); + dispose(); + assert.equal(cleared, 23); +}); + + +test("Workbench refresh starts a new overview read after a slow initial load", async () => { + const overviewRequests = []; + let refreshCalls = 0; + const fixture = installPanel((path, init) => { + if (path.endsWith("/refresh")) { + refreshCalls += 1; + return response({ status: "refreshing" }, 202); + } + return new Promise((resolve) => overviewRequests.push({ resolve, signal: init.signal })); + }); + let delayedRefresh; + fixture.window.setTimeout = (callback) => { delayedRefresh = callback; return 23; }; + + const dispose = fixture.panel.render(fixture.host); + fixture.host.querySelector("[data-fitbit-refresh]").click(); + await settle(); + delayedRefresh(); + + assert.equal(refreshCalls, 1); + assert.equal(overviewRequests.length, 2); + assert.equal(overviewRequests[0].signal.aborted, true); + overviewRequests[1].resolve(response(overview)); + await settle(); + assert.equal(fixture.host.querySelector("[data-fitbit-state]").textContent, "清醒"); + dispose(); +}); + + +test("Workbench panel requests a plugin-owned authorization URL before opening it", async () => { + const calls = []; + const fixture = installPanel(async (path) => { + calls.push(path); + return response(path.endsWith("/auth/start") + ? { url: "https://www.fitbit.com/oauth2/authorize?client_id=test" } + : overview); + }); + const opened = []; + const authorizationWindow = { + close() {}, + location: { replace(url) { opened.push(["replace", url]); } }, + }; + fixture.window.open = (...args) => { + opened.push(args); + return authorizationWindow; + }; + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-auth]").click(); + await settle(); + + assert.deepEqual(calls, ["/api/dashboard/fitbit/overview", "/api/dashboard/fitbit/auth/start"]); + assert.equal(opened.length, 2); + assert.equal(opened[0][0], "about:blank"); + assert.equal(opened[0][1], "_blank"); + assert.equal(opened[1][0], "replace"); + assert.equal(opened[1][1], "https://www.fitbit.com/oauth2/authorize?client_id=test"); + assert.equal(authorizationWindow.opener, null); + assert.equal(fixture.host.querySelector("a[href]"), null); + dispose(); +}); + + +test("Workbench disposer closes an authorization popup before navigation commits", async () => { + const fixture = installPanel((path) => path.endsWith("/auth/start") + ? new Promise(() => {}) + : Promise.resolve(response(overview))); + let closed = false; + fixture.window.open = () => ({ + close() { closed = true; }, + location: { replace() {} }, + }); + + const dispose = fixture.panel.render(fixture.host); + await settle(); + fixture.host.querySelector("[data-fitbit-auth]").click(); + dispose(); + + assert.equal(closed, true); +}); diff --git a/dashboard_panel.css b/web_module.css similarity index 99% rename from dashboard_panel.css rename to web_module.css index 1c23145..7b0686b 100644 --- a/dashboard_panel.css +++ b/web_module.css @@ -1,3 +1,4 @@ +/* Workbench panel styles are scoped to the Fitbit root. */ .fitbit-dashboard { --fitbit-primary: oklch(0.74 0.1 270); --fitbit-on-primary: oklch(0.18 0.025 270); @@ -134,8 +135,10 @@ background: var(--fitbit-primary-container); } -.fitbit-dashboard__button:hover { - filter: brightness(1.06); +@media (hover: hover) { + .fitbit-dashboard__button:hover { + filter: brightness(1.06); + } } .fitbit-dashboard__button:active { diff --git a/dashboard_panel.js b/web_module.js similarity index 76% rename from dashboard_panel.js rename to web_module.js index 4b98dda..a37029a 100644 --- a/dashboard_panel.js +++ b/web_module.js @@ -1,4 +1,13 @@ -const api = window.api; +export function activate(ctx) { + return ctx.ui.inject("workbench.panels.v1", (mount) => mount.register({ + id: "fitbit", + label: "Fitbit 健康", + order: 40, + render(host) { + return renderFitbitDashboard(host, ctx); + }, + })); +} function number(value, digits = 0) { if (value === null || value === undefined) return "—"; @@ -214,8 +223,7 @@ function showError(root, error) { errorNode.querySelector("span").textContent = error instanceof Error ? error.message : "Fitbit 数据读取失败"; } -function renderFitbitDashboard(container) { - container.__fitbitDashboardDispose?.(); +function renderFitbitDashboard(container, ctx) { container.innerHTML = `