/** * E2E test scenarios for issues #30 and #31. * * #30: Task without last_performed should anchor next_due on created_at, * not "today" every refresh. * #31: Reset prompt strings should make it clear that the date is the * "last performed" date, not the next due date. * * Usage: * docker compose up -d homeassistant-dev playwright * docker restart playwright-server * cd custom_components/maintenance_supporter/frontend-src * node e2e-issues-30-31.mjs */ import { setup, ws, cleanup } from "./e2e-helpers.mjs"; import { readFile } from "fs/promises"; let passed = 0, failed = 0; function check(name, ok, detail) { if (ok) { passed++; console.log(" PASS " + name); } else { failed++; console.log(" FAIL " + name + (detail ? " — " + detail : "")); } } async function runTest(name, fn) { console.log("\n=== " + name + " ==="); try { await fn(); } catch (e) { failed++; console.log(" ERROR: " + e.message + (e.stack ? "\n" + e.stack : "")); } } const { browser, ctx, page } = await setup(); const RUN = `${Date.now().toString(36)}`; // Helper: create a temp object + task on a fresh entry, return ids. async function wsLog(cmd) { try { return await ws(page, cmd); } catch (e) { const detail = e.message + " · cmd=" + JSON.stringify(cmd); throw new Error(detail); } } async function createScenarioObject(name, taskOptions) { const created = await wsLog({ type: "maintenance_supporter/object/create", name, }); const entryId = created.entry_id; const taskRes = await wsLog({ type: "maintenance_supporter/task/create", entry_id: entryId, ...taskOptions, }); return { entryId, taskId: taskRes.task_id }; } async function readTask(entryId, taskId) { const obj = await ws(page, { type: "maintenance_supporter/object", entry_id: entryId }); return obj.tasks.find(t => t.id === taskId); } async function deleteEntry(entryId) { try { await page.evaluate(async (eid) => { const ha = document.querySelector("home-assistant"); const conn = ha.__hass.connection; await conn.sendMessagePromise({ type: "config_entries/remove", entry_id: eid, }); }, entryId); } catch (e) { console.log(" cleanup: ", e.message); } } // ===================================================================== // SCENARIO #30: created_at fallback for tasks without last_performed // ===================================================================== await runTest("#30 — Task ohne last_performed bekommt created_at=today", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Issue30 A ${RUN}`, { name: "Daily Task", task_type: "cleaning", schedule_type: "time_based", interval_days: 1, warning_days: 0, enabled: true, }); const task = await readTask(entryId, taskId); console.log(` debug: next_due=${task.next_due} days_until_due=${task.days_until_due} last_performed=${task.last_performed}`); // The frontend doesn't expose created_at, but we know that with last_performed=null // and a fresh anchor, days_until_due must equal interval_days (1 here). check("days_until_due equals interval_days", task.days_until_due === 1, `got ${task.days_until_due} (expected 1)`); check("status is OK with warning_days=0", task.status === "ok", `got ${task.status}`); await deleteEntry(entryId); }); await runTest("#30 — Interval=30 ergibt days_until_due=30", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Issue30 B ${RUN}`, { name: "Monthly Task", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, warning_days: 7, enabled: true, }); const task = await readTask(entryId, taskId); console.log(` debug: next_due=${task.next_due} days_until_due=${task.days_until_due}`); check("days_until_due equals interval_days (30)", task.days_until_due === 30, `got ${task.days_until_due}`); check("status is OK (not DUE_SOON)", task.status === "ok", `got ${task.status}`); await deleteEntry(entryId); }); await runTest("#30 — Refresh ändert next_due nicht (Anker stabil)", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Issue30 C ${RUN}`, { name: "Stable Date", task_type: "cleaning", schedule_type: "time_based", interval_days: 7, enabled: true, }); const t1 = await readTask(entryId, taskId); // Force a refresh by sleeping briefly await page.waitForTimeout(1500); const t2 = await readTask(entryId, taskId); check("next_due stable across refreshes", t1.next_due === t2.next_due, `t1=${t1.next_due}, t2=${t2.next_due}`); await deleteEntry(entryId); }); await runTest("#30 — Task mit last_performed nutzt last_performed (nicht created_at)", async () => { const past = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10); const { entryId, taskId } = await createScenarioObject(`E2E Issue30 D ${RUN}`, { name: "Past LP Task", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, last_performed: past, enabled: true, }); const task = await readTask(entryId, taskId); // last_performed=60 days ago + interval=30 → next_due was 30 days ago → OVERDUE check("status is OVERDUE", task.status === "overdue", `got ${task.status}`); check("days_until_due negative", task.days_until_due < 0, `got ${task.days_until_due}`); await deleteEntry(entryId); }); // ===================================================================== // SCENARIO #31: Reset prompt strings clearer // ===================================================================== await runTest("#31 — Reset-Prompt Label sagt 'Last performed date'", async () => { // Create a task to reset const { entryId, taskId } = await createScenarioObject(`E2E Issue31 ${RUN}`, { name: "Reset Demo", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, enabled: true, }); await page.goto("http://homeassistant-dev:8123/maintenance-supporter"); await page.waitForTimeout(3000); // Verify shipped strings via the built JS file (runs on host fs, not browser). const builtJs = await readFile( new URL("../frontend/maintenance-panel.js", import.meta.url), "utf-8", ); check("EN reset_date_optional shipped", builtJs.includes("Last performed date (optional, defaults to today)")); check("EN reset_date_prompt shipped", builtJs.includes("Mark task as performed?")); check("DE reset_date_optional shipped", builtJs.includes("Letztes Erledigungs-Datum")); check("Old confusing string removed", !builtJs.includes("Reset this task?"), "old 'Reset this task?' still present"); // Reset via WS using the new label semantics: setting the date sets last_performed. const resetDate = "2025-01-15"; await ws(page, { type: "maintenance_supporter/task/reset", entry_id: entryId, task_id: taskId, date: resetDate, }); const task = await readTask(entryId, taskId); check("reset sets last_performed", task.last_performed === resetDate, `got ${task.last_performed}`); // next_due = last_performed + interval = 2025-01-15 + 30 = 2025-02-14 check("next_due = last_performed + interval", task.next_due === "2025-02-14", `got ${task.next_due}`); await deleteEntry(entryId); }); // ===================================================================== // EDGE CASES: TZ + lifecycle interaction // ===================================================================== await runTest("EDGE — Lifecycle: create → complete → next_due aktualisiert", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Edge LC ${RUN}`, { name: "Lifecycle Task", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, warning_days: 7, enabled: true, }); // Initial state: no last_performed, days_until_due == interval_days const t1 = await readTask(entryId, taskId); check("initial days_until_due == interval_days", t1.days_until_due === 30, `got ${t1.days_until_due}`); check("initial last_performed is null", t1.last_performed === null, `got ${t1.last_performed}`); // Complete via WS await ws(page, { type: "maintenance_supporter/task/complete", entry_id: entryId, task_id: taskId, }); const t2 = await readTask(entryId, taskId); check("after complete: last_performed set", t2.last_performed !== null, `got ${t2.last_performed}`); check("after complete: days_until_due == interval", t2.days_until_due === 30, `got ${t2.days_until_due}`); // History entry written check("after complete: history has entry", t2.history.length === 1, `len=${t2.history.length}`); // Timestamp must be TZ-aware const ts = t2.history[0].timestamp; check("history timestamp has TZ suffix", /[+-]\d{2}:\d{2}$|Z$/.test(ts), `ts=${ts}`); await deleteEntry(entryId); }); await runTest("EDGE — Reset zu vergangenem Datum: last_performed + history", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Edge RST ${RUN}`, { name: "Reset Edge", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, enabled: true, }); const past = "2025-01-15"; await ws(page, { type: "maintenance_supporter/task/reset", entry_id: entryId, task_id: taskId, date: past, }); const t = await readTask(entryId, taskId); check("reset: last_performed = past date", t.last_performed === past, `got ${t.last_performed}`); check("reset: next_due = past + interval (overdue)", t.next_due === "2025-02-14", `got ${t.next_due}`); check("reset: status is overdue", t.status === "overdue", `got ${t.status}`); check("reset: history has reset entry", t.history.some(h => h.type === "reset"), `types=${t.history.map(h => h.type).join(",")}`); await deleteEntry(entryId); }); await runTest("EDGE — Skip via WS: history entry + last_performed bumped", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Edge SKP ${RUN}`, { name: "Skip Edge", task_type: "cleaning", schedule_type: "time_based", interval_days: 14, enabled: true, }); await ws(page, { type: "maintenance_supporter/task/skip", entry_id: entryId, task_id: taskId, reason: "user busy", }); const t = await readTask(entryId, taskId); check("skip: last_performed set (today)", t.last_performed !== null); check("skip: history has skip entry", t.history.some(h => h.type === "skipped"), `types=${t.history.map(h => h.type).join(",")}`); check("skip: next_due == today + interval (14)", t.days_until_due === 14, `got ${t.days_until_due}`); await deleteEntry(entryId); }); await runTest("EDGE — Update last_performed setzt next_due neu", async () => { const { entryId, taskId } = await createScenarioObject(`E2E Edge UPD ${RUN}`, { name: "Update Edge", task_type: "cleaning", schedule_type: "time_based", interval_days: 30, enabled: true, }); const past = "2024-06-01"; await ws(page, { type: "maintenance_supporter/task/update", entry_id: entryId, task_id: taskId, last_performed: past, }); const t = await readTask(entryId, taskId); check("update: last_performed reflected", t.last_performed === past, `got ${t.last_performed}`); check("update: next_due = last + interval", t.next_due === "2024-07-01", `got ${t.next_due}`); await deleteEntry(entryId); }); // ===================================================================== await cleanup(browser, ctx); console.log("\n" + "═".repeat(50)); console.log(`RESULTS: ${passed} passed, ${failed} failed`); if (failed > 0) { console.log("SOME TESTS FAILED!"); process.exit(1); } else console.log("ALL TESTS PASSED!");