276 lines
9.6 KiB
JavaScript
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);
|