New apps Added
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
/** 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);
|
||||
Reference in New Issue
Block a user