diff --git a/AGENTS.md b/AGENTS.md index aaff844dc..ea5e80d5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,3 +270,20 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w - Implement proper caching strategies and connection pooling - Avoid blocking operations in main application loop - Include appropriate comments for complex business logic + +## Pull Request Descriptions + +Structure PR descriptions in this order. No headlines. Be concise. + +1. **References first line**: link related issues or PRs (`fixes #1123`, `replaces #222`, `pairs with org/repo#345`). PRs should almost always reference an issue or related PR — only skip in rare exceptions (e.g. trivial typo fixes). +2. **Intro**: one or a few concise sentences framing what the PR does and why it was created this way. The full problem description belongs in the linked issue, not here. +3. **Bullet list**: most significant changes or user-facing implications. Lead with the most significant. +4. **TODO section** (only if open points remain): + + ``` + **TODO** + - [ ] item a + - [ ] item b + ``` + +Avoid file paths, line numbers, or code listings reproduced from the diff. Include a code snippet only when it conveys the contract (event shape, API signature) more clearly than prose. No testing checklists, no co-author footers, no generator footers. diff --git a/assets/js/components/Config/BackupRestoreModal.vue b/assets/js/components/Config/BackupRestoreModal.vue index 88c2105e4..67d673c02 100644 --- a/assets/js/components/Config/BackupRestoreModal.vue +++ b/assets/js/components/Config/BackupRestoreModal.vue @@ -170,6 +170,7 @@ import { defineComponent } from "vue"; import GenericModal from "../Helper/GenericModal.vue"; import api, { downloadFile } from "@/api"; +import { dispatchDownload } from "@/utils/native"; import PropertyFileField from "./PropertyFileField.vue"; import FormRow from "./FormRow.vue"; import { isLoggedIn } from "../Auth/auth"; @@ -296,6 +297,10 @@ export default defineComponent({ return r; }, async downloadBackup() { + if (dispatchDownload("/api/system/backup", "POST", { password: this.password })) { + this.closeConfirmModal(); + return; + } const res = await this.call( api.post( "/system/backup", diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index 8f1b99fd4..d215cafc5 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -25,7 +25,12 @@ $t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) }) }} - + {{ $t("config.hems.downloadCsv") }} @@ -40,6 +45,7 @@ import YamlModal from "./YamlModal.vue"; import defaultYaml from "./defaultYaml/hems.yaml?raw"; import api from "../../api"; +import { handleDownloadClick } from "../../utils/native"; import formatter from "../../mixins/formatter"; export default { @@ -78,6 +84,7 @@ export default { }, }, methods: { + handleDownloadClick, async loadSessions() { try { const response = await api.get("gridsessions", { diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index f2b54fbd7..c5cbdc156 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -19,6 +19,7 @@ declare global { } interface Window { ReactNativeWebView?: WebView; + evccAppCapabilities?: string[]; } } diff --git a/assets/js/utils/native.ts b/assets/js/utils/native.ts index 852f9d6bb..a93b334fd 100644 --- a/assets/js/utils/native.ts +++ b/assets/js/utils/native.ts @@ -9,6 +9,27 @@ export function appDetection() { } } -export function sendToApp(data: { type: string }) { +export function hasAppCapability(capability: string): boolean { + return isApp() && window.evccAppCapabilities?.includes(capability) === true; +} + +type AppMessage = + | { type: "online" | "offline" | "settings" } + | { type: "download"; url: string; method?: string; body?: unknown }; + +export function sendToApp(data: AppMessage) { window.ReactNativeWebView?.postMessage(JSON.stringify(data)); } + +export function handleDownloadClick(event: Event, url: string) { + if (dispatchDownload(url)) { + event.preventDefault(); + } +} + +export function dispatchDownload(url: string, method?: string, body?: unknown): boolean { + if (!hasAppCapability("download")) return false; + const absolute = new URL(url, window.location.href).toString(); + sendToApp({ type: "download", url: absolute, method, body }); + return true; +} diff --git a/assets/js/views/History.vue b/assets/js/views/History.vue index b8fa80235..edac95fc2 100644 --- a/assets/js/views/History.vue +++ b/assets/js/views/History.vue @@ -100,6 +100,7 @@ download class="text-muted small history-csv-link" data-testid="history-csv-download" + @click="handleDownloadClick($event, csvLink)" > {{ $t("main.history.downloadCsv") }} @@ -126,6 +127,7 @@ import { PERIODS } from "../components/Sessions/types"; import { GROUP_ORDER, groupColor } from "../components/History/groups"; import colors, { resolveColors, deviceColorMap } from "../colors"; import LegendList from "../components/Sessions/LegendList.vue"; +import { handleDownloadClick } from "@/utils/native"; import formatter, { POWER_UNIT } from "../mixins/formatter"; import api from "../api"; import store from "../store"; @@ -368,6 +370,7 @@ export default defineComponent({ }, methods: { groupColor, + handleDownloadClick, legendsForGroup(group: string): Legend[] { const items = this.entityLegends(group); const focused = this.focusedEntity[group] ?? null; diff --git a/assets/js/views/Log.vue b/assets/js/views/Log.vue index 436e8e0a7..bc99a324a 100644 --- a/assets/js/views/Log.vue +++ b/assets/js/views/Log.vue @@ -28,6 +28,7 @@ :aria-label="$t('log.download')" :href="downloadUrl" download + @click="handleDownloadClick($event, downloadUrl)" > l.toUpperCase()).join("|")})`); @@ -222,6 +224,7 @@ export default defineComponent({ this.stopInterval(); }, methods: { + handleDownloadClick, async updateLogs(showAll: boolean = false) { // prevent concurrent requests if (this.busy) return; diff --git a/assets/js/views/Sessions.vue b/assets/js/views/Sessions.vue index 752986107..a8fe22f48 100644 --- a/assets/js/views/Sessions.vue +++ b/assets/js/views/Sessions.vue @@ -159,6 +159,7 @@ :href="csvLink" download data-testid="sessions-download" + @click="handleDownloadClick($event, csvLink)" > {{ csvLinkLabel }} @@ -211,6 +212,7 @@ import settings from "../settings"; import PeriodSelector from "../components/Sessions/PeriodSelector.vue"; import DateNavigator from "../components/Sessions/DateNavigator.vue"; import PeriodHeader from "../components/Sessions/PeriodHeader.vue"; +import { handleDownloadClick } from "@/utils/native"; import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue"; import TotalIcon from "../components/MaterialIcon/Total.vue"; import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types"; @@ -671,6 +673,7 @@ export default defineComponent({ this.loadSessions(); }, methods: { + handleDownloadClick, changePeriod(newPeriod: PERIODS) { let month: number | undefined = this.month; let year: number | undefined = this.year; diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts index 3c9660f0c..8b9c02333 100644 --- a/tests/backup-restore.spec.ts +++ b/tests/backup-restore.spec.ts @@ -1,6 +1,12 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl, restart } from "./evcc"; -import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils"; +import { + openMoreMenu, + expectModalVisible, + expectModalHidden, + enableAppContext, + expectAppEvent, +} from "./utils"; import fs from "fs"; import path from "path"; @@ -247,3 +253,28 @@ test.describe("backup and restore", async () => { await stop(); }); }); + +test.describe("backup in app context", async () => { + test("download backup dispatches POST event with password body", async ({ page }) => { + await enableAppContext(page); + await start(); + await page.goto("/#/config"); + + await page.getByRole("button", { name: "Backup & Restore" }).click(); + const backupModal = page.getByTestId("backup-restore-modal"); + await expectModalVisible(backupModal); + + await backupModal.getByRole("button", { name: "Download backup..." }).click(); + const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal"); + await expectModalVisible(backupConfirmModal); + + await backupConfirmModal.getByRole("button", { name: "Download backup" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/system/backup"), + method: "POST", + body: { password: "" }, + }); + await stop(); + }); +}); diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 0f7324cf2..6a406b75f 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -1,6 +1,13 @@ import { test, expect } from "@playwright/test"; import { start, stop, restart, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden, editorClear, editorPaste } from "./utils"; +import { + expectModalVisible, + expectModalHidden, + editorClear, + editorPaste, + enableAppContext, + expectAppEvent, +} from "./utils"; import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator"; test.use({ baseURL: baseUrl() }); @@ -109,6 +116,25 @@ limit: await stopSimulator(); }); + test.describe("grid sessions CSV in app context", () => { + test("dispatches download event", async ({ page }) => { + await enableAppContext(page); + await start(CONFIG, "hems.sql"); + await page.goto("/#/config"); + + await page.getByTestId("hems").getByRole("button", { name: "edit" }).click(); + const hemsModal = page.getByTestId("hems-modal"); + await expectModalVisible(hemsModal); + + const csvLink = hemsModal.getByRole("link", { name: "Download CSV" }); + await csvLink.click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/gridsessions?format=csv&lang=en"), + }); + }); + }); + test("external control with circuits", async ({ page }) => { const GRID_CONFIG = "hems-grid.evcc.yaml"; await start(GRID_CONFIG); diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts index 111571727..f9337918b 100644 --- a/tests/logs.spec.ts +++ b/tests/logs.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { openMoreMenu } from "./utils"; +import { openMoreMenu, enableAppContext, expectAppEvent } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -49,3 +49,15 @@ test.describe("features", async () => { await expect(page.getByTestId("log-content")).toContainText("UI local"); }); }); + +test.describe("log download in app context", async () => { + test("dispatches download event", async ({ page }) => { + await enableAppContext(page); + await page.goto("/#/log"); + await page.getByRole("link", { name: "Download complete log" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/system/log?level=debug&format=txt"), + }); + }); +}); diff --git a/tests/sessions.spec.ts b/tests/sessions.spec.ts index 4e4efe7dd..ee2010db0 100644 --- a/tests/sessions.spec.ts +++ b/tests/sessions.spec.ts @@ -1,6 +1,6 @@ import { test, expect, devices, type Page } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalVisible, expectModalHidden } from "./utils"; +import { expectModalVisible, expectModalHidden, enableAppContext, expectAppEvent } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -309,6 +309,26 @@ test.describe("csv export", async () => { }); }); +test.describe("csv export download", async () => { + test("in browser context", async ({ page }) => { + await page.goto("/#/sessions?period=total"); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("link", { name: "Download total CSV" }).click(); + const download = await downloadPromise; + expect(download.url()).toContain("/api/sessions?format=csv"); + }); + + test("in app context", async ({ page }) => { + await enableAppContext(page); + await page.goto("/#/sessions?period=total"); + await page.getByRole("link", { name: "Download total CSV" }).click(); + expect(await expectAppEvent(page)).toMatchObject({ + type: "download", + url: expect.stringContaining("/api/sessions?format=csv&lang=en"), + }); + }); +}); + test.describe("session details", async () => { test("show session details (session 5)", async ({ page }) => { await page.goto("/#/sessions?year=2023&month=5"); diff --git a/tests/utils.ts b/tests/utils.ts index 8977b1e3d..0d9403daa 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -130,3 +130,33 @@ export async function getDatalistOptions(input: Locator): Promise { return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value); }); } + +type AppState = { + evccAppCapabilities: string[]; + __appEvent: unknown; + ReactNativeWebView: { postMessage: (m: string) => void }; +}; + +export async function enableAppContext(page: Page): Promise { + await page.addInitScript(() => { + Object.defineProperty(navigator, "userAgent", { + value: "evcc/playwright", + configurable: true, + }); + const w = window as unknown as AppState; + w.evccAppCapabilities = ["download"]; + w.__appEvent = undefined; + w.ReactNativeWebView = { + postMessage: (msg: string) => { + w.__appEvent = JSON.parse(msg); + }, + }; + }); +} + +export async function expectAppEvent(page: Page): Promise { + await expect + .poll(async () => page.evaluate(() => (window as unknown as AppState).__appEvent !== undefined)) + .toBe(true); + return page.evaluate(() => (window as unknown as AppState).__appEvent); +}