Files
Home-Assistant/custom_components/maintenance_supporter/frontend-src/verify-history-edit-e2e.mjs
T
2026-07-08 10:43:39 -04:00

276 lines
9.6 KiB
JavaScript

/** v2.2.0 — intensive E2E for the history-edit feature.
*
* The user (Discussion #49 follow-up) explicitly asked: "das müsste intensiv
* getestet werden ob die Änderungen korrekt gespeichert werden". This script
* exercises the full roundtrip via the live HA WebSocket:
*
* 1. find an existing maintenance object + completed task with ≥1 history entry
* 2. record the original entry payload
* 3. send maintenance_supporter/task/history/update with patched fields
* 4. fetch the object again, verify the patch landed
* 5. test edge cases: clear notes (None), edit timestamp updates last_performed
* 6. read back AGAIN after a brief wait — confirms persistence isn't lost
*
* No browser UI needed — this is pure WS roundtrip, the most reliable layer.
*/
import { chromium } from "playwright";
const HA = "http://localhost:8125";
const HA_TOKEN = process.env.HA_TOKEN;
if (!HA_TOKEN) {
console.error("Set HA_TOKEN env var.");
process.exit(1);
}
async function getRefreshToken() {
async function post(path, body, ctype = "application/json") {
const data = typeof body === "string" ? body : JSON.stringify(body);
const r = await fetch(`${HA}${path}`, {
method: "POST",
headers: { "Content-Type": ctype },
body: data,
});
return await r.json();
}
const flow = await post("/auth/login_flow", {
client_id: `${HA}/`, handler: ["homeassistant", null], redirect_uri: `${HA}/`,
});
const auth = await post(`/auth/login_flow/${flow.flow_id}`, {
client_id: `${HA}/`, username: "dev", password: "dev",
});
const tokens = await post(
"/auth/token",
`grant_type=authorization_code&code=${auth.result}&client_id=${HA}/`,
"application/x-www-form-urlencoded",
);
return tokens.refresh_token;
}
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
const page = await ctx.newPage();
const refreshToken = await getRefreshToken();
await page.goto(HA);
await page.waitForTimeout(800);
await page.evaluate(({ ha, r }) => {
localStorage.setItem("hassTokens", JSON.stringify({
hassUrl: ha, clientId: `${ha}/`, refresh_token: r,
access_token: "", token_type: "Bearer", expires_in: 1800, expires: 0,
}));
}, { ha: HA, r: refreshToken });
await page.goto(HA);
await page.waitForTimeout(8000);
// All assertions go through this — runs in browser context with hass.connection
const result = await page.evaluate(async () => {
const out = { steps: [] };
const log = (msg, data) => out.steps.push({ msg, ...(data ? { data } : {}) });
const hass = document.querySelector("home-assistant")?.hass;
if (!hass) {
out.error = "no hass";
return out;
}
const ws = (m) => hass.connection.sendMessagePromise(m);
// 1. Find an object with a completed task that has ≥1 history entry
const { objects } = await ws({ type: "maintenance_supporter/objects" });
let testEntry = null;
let testTask = null;
for (const obj of objects) {
for (const task of obj.tasks || []) {
if (Array.isArray(task.history) && task.history.length >= 1) {
testEntry = obj;
testTask = task;
break;
}
}
if (testEntry) break;
}
if (!testEntry) {
// None exists — create a fresh object + task + complete it once
log("no history found, creating one");
const cr = await ws({
type: "maintenance_supporter/object/create",
name: `histtest-${Math.random().toString(36).slice(2, 8)}`,
});
const newEntryId = cr.entry_id;
await new Promise(r => setTimeout(r, 500));
const tr = await ws({
type: "maintenance_supporter/task/create",
entry_id: newEntryId,
name: "edit-test-task",
schedule_type: "time_based",
interval_days: 30,
});
const newTaskId = tr.task_id;
await new Promise(r => setTimeout(r, 500));
await ws({
type: "maintenance_supporter/task/complete",
entry_id: newEntryId,
task_id: newTaskId,
notes: "initial completion",
cost: 25.0,
duration: 45,
});
await new Promise(r => setTimeout(r, 800));
const fresh = await ws({
type: "maintenance_supporter/object",
entry_id: newEntryId,
});
testEntry = { entry_id: newEntryId };
testTask = fresh.tasks.find(t => t.id === newTaskId);
}
log("test target", {
entry_id: testEntry.entry_id,
task_id: testTask.id,
history_count: testTask.history.length,
});
// 2. Take the FIRST completed entry (most likely to be lifecycle-relevant)
const original = testTask.history.find(h => h.type === "completed")
|| testTask.history[0];
const originalTs = original.timestamp;
log("original entry", { ts: originalTs, notes: original.notes, cost: original.cost });
// 3. PATCH 1 — change notes only
const patchedNotes = `e2e-patch-${Date.now()}`;
const r1 = await ws({
type: "maintenance_supporter/task/history/update",
entry_id: testEntry.entry_id,
task_id: testTask.id,
original_timestamp: originalTs,
notes: patchedNotes,
});
log("patch1 result", r1);
out.patch1_ok = r1.success === true;
// 4. Verify roundtrip
await new Promise(r => setTimeout(r, 800));
const round1 = await ws({
type: "maintenance_supporter/object",
entry_id: testEntry.entry_id,
});
const taskAfter1 = round1.tasks.find(t => t.id === testTask.id);
const entryAfter1 = taskAfter1.history.find(h => h.timestamp === originalTs);
out.notes_persisted = entryAfter1?.notes === patchedNotes;
log("after patch1", { notes: entryAfter1?.notes });
// 5. PATCH 2 — change cost AND duration in one call
const r2 = await ws({
type: "maintenance_supporter/task/history/update",
entry_id: testEntry.entry_id,
task_id: testTask.id,
original_timestamp: originalTs,
cost: 99.95,
duration: 123,
});
out.patch2_ok = r2.success === true;
await new Promise(r => setTimeout(r, 800));
const round2 = await ws({
type: "maintenance_supporter/object",
entry_id: testEntry.entry_id,
});
const taskAfter2 = round2.tasks.find(t => t.id === testTask.id);
const entryAfter2 = taskAfter2.history.find(h => h.timestamp === originalTs);
out.cost_persisted = entryAfter2?.cost === 99.95;
out.duration_persisted = entryAfter2?.duration === 123;
out.notes_still_there = entryAfter2?.notes === patchedNotes; // unchanged
out.type_unchanged = entryAfter2?.type === original.type; // never patchable
// 6. PATCH 3 — clear notes via null
const r3 = await ws({
type: "maintenance_supporter/task/history/update",
entry_id: testEntry.entry_id,
task_id: testTask.id,
original_timestamp: originalTs,
notes: null,
});
out.patch3_ok = r3.success === true;
await new Promise(r => setTimeout(r, 800));
const round3 = await ws({
type: "maintenance_supporter/object",
entry_id: testEntry.entry_id,
});
const taskAfter3 = round3.tasks.find(t => t.id === testTask.id);
const entryAfter3 = taskAfter3.history.find(h => h.timestamp === originalTs);
out.notes_cleared = entryAfter3?.notes == null;
// 7. PATCH 4 — change timestamp; verify last_performed follows when this
// is the latest lifecycle entry
const lifecycleEntries = taskAfter3.history.filter(
h => ["completed", "reset", "skipped"].includes(h.type),
);
const latestTs = lifecycleEntries.reduce(
(max, h) => h.timestamp > max ? h.timestamp : max,
"",
);
const isLatest = entryAfter3.timestamp === latestTs;
out.is_latest_lifecycle = isLatest;
if (isLatest) {
// Bump timestamp by 1 hour
const dt = new Date(entryAfter3.timestamp);
dt.setHours(dt.getHours() + 1);
const newTs = dt.toISOString();
const r4 = await ws({
type: "maintenance_supporter/task/history/update",
entry_id: testEntry.entry_id,
task_id: testTask.id,
original_timestamp: entryAfter3.timestamp,
timestamp: newTs,
});
out.patch4_ok = r4.success === true;
await new Promise(r => setTimeout(r, 800));
const round4 = await ws({
type: "maintenance_supporter/object",
entry_id: testEntry.entry_id,
});
const taskAfter4 = round4.tasks.find(t => t.id === testTask.id);
const entryAfter4 = taskAfter4.history.find(h => h.timestamp === newTs);
out.timestamp_persisted = entryAfter4 != null;
out.last_performed_followed =
taskAfter4.last_performed === newTs.slice(0, 10);
}
// 8. INVALID timestamp rejected
try {
await ws({
type: "maintenance_supporter/task/history/update",
entry_id: testEntry.entry_id,
task_id: testTask.id,
original_timestamp: "nonexistent-2020-01-01",
notes: "oops",
});
out.invalid_ts_rejected = false;
} catch (e) {
out.invalid_ts_rejected = true;
out.invalid_ts_error = String(e).substring(0, 200);
}
return out;
});
console.log(JSON.stringify(result, null, 2));
const summary = [
["patch1 (notes) ", result.patch1_ok && result.notes_persisted],
["patch2 (cost+duration) ", result.patch2_ok && result.cost_persisted && result.duration_persisted],
["patch2 leaves notes intact ", result.notes_still_there],
["patch2 leaves type intact ", result.type_unchanged],
["patch3 (clear notes via null)", result.patch3_ok && result.notes_cleared],
["patch4 (timestamp) ", result.is_latest_lifecycle ? (result.patch4_ok && result.timestamp_persisted) : true],
["last_performed follows latest", result.is_latest_lifecycle ? result.last_performed_followed : true],
["invalid original_ts rejected ", result.invalid_ts_rejected],
];
console.log("\n=== SUMMARY ===");
let allOk = true;
for (const [label, ok] of summary) {
console.log(` ${ok ? "✓" : "✗"} ${label}`);
if (!ok) allOk = false;
}
await browser.close();
process.exit(allOk ? 0 : 1);