From ab8efe361d34e4256c522e99020a779d58292a43 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sat, 14 Nov 2026 23:29:42 +0000 Subject: [PATCH 1/2] ci: give the card test job a 15 minute timeout npm test takes about a minute. Without a limit a hung test held the job for GitHub's six-hour default. --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f8673d03..713a01fe 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -45,6 +45,9 @@ jobs: eslint: name: ESLint (cards + panel) runs-on: ubuntu-latest + # The card render tests finish in about a minute. A stuck timer in a test + # once held the job until GitHub's six-hour default; fail fast instead. + timeout-minutes: 15 steps: - name: Check out repository From fc5ab7b12d688d640621f4977afae8d71d8727f7 Mon Sep 17 00:00:00 2001 From: tempus2016 Date: Sat, 14 Nov 2026 23:29:42 +0000 Subject: [PATCH 2/2] fix(tests): stop the frontend suite depending on the time of day The kid tour tests stamped a completion at 08:00 today, which before 08:00 is in the future, so the child card's settle timer waited until then and the test file never exited. The setup wizard tests built birthdays from the UTC date, a day off east of UTC late in the evening. The card now also treats a completion stamped ahead of the browser's clock as made just now, so its settle timer never runs longer than the settle window. --- .../taskmate/www/taskmate-child-card.js | 5 +- .../taskmate-child-card-settle-timer.test.cjs | 73 +++++++++++++++++++ tests/frontend/taskmate-kid-tour.test.cjs | 11 ++- .../taskmate-panel-setup-wizard.test.cjs | 14 +++- 4 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 tests/frontend/taskmate-child-card-settle-timer.test.cjs diff --git a/custom_components/taskmate/www/taskmate-child-card.js b/custom_components/taskmate/www/taskmate-child-card.js index 475bcaf6..32521c2d 100644 --- a/custom_components/taskmate/www/taskmate-child-card.js +++ b/custom_components/taskmate/www/taskmate-child-card.js @@ -4439,7 +4439,10 @@ class TaskMateChildCard extends LitElement { // A tap still in flight counts from the moment it was made. const opt = this._optimisticCompletions?.[`${baseId}_${child.id}`]; const optTimes = opt ? (opt.timestamps || [opt.timestamp || now]) : []; - const latest = Math.max(0, ...mine.map(c => Date.parse(c.completed_at || "") || 0), ...optTimes); + // A stamp ahead of this browser's clock (server clock skew) counts as + // made just now, so the settle timer never waits longer than the + // settle window itself. + const latest = Math.min(now, Math.max(0, ...mine.map(c => Date.parse(c.completed_at || "") || 0), ...optTimes)); if (latest && now - latest < this.constructor.AVAILABLE_FIRST_SETTLE_MS) { settleAt = Math.min(settleAt, latest + this.constructor.AVAILABLE_FIRST_SETTLE_MS); return 0; diff --git a/tests/frontend/taskmate-child-card-settle-timer.test.cjs b/tests/frontend/taskmate-child-card-settle-timer.test.cjs new file mode 100644 index 00000000..b7d0cac8 --- /dev/null +++ b/tests/frontend/taskmate-child-card-settle-timer.test.cjs @@ -0,0 +1,73 @@ +// The "available first" sort keeps a just-ticked chore in place for a few +// seconds with a real timer (_availableFirst's settle timer). A completion +// stamped ahead of the browser's clock (server clock skew, or a test fixture +// set later in the day) must not stretch that timer past the settle window: +// a timer hours long kept the card re-rendering late and kept Node test +// files from exiting until the stamp came round. + +const assert = require("node:assert/strict"); +const { test } = require("node:test"); + +const { loadCard } = require("./harness.cjs"); + +const ENTITY = "sensor.taskmate_overview"; + +function cardWithTimers() { + const delays = []; + const { get } = loadCard("taskmate-child-card.js", { + sandbox: { + setTimeout: (_fn, ms) => { delays.push(ms); return delays.length; }, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + }, + }); + const ChildCard = get("taskmate-child-card"); + const card = new ChildCard(); + card.setConfig({ entity: ENTITY, child_id: "kid1" }); + card._loading = {}; + card._optimisticCompletions = {}; + return { card, delays }; +} + +function chore(id, name) { + return { id, name, points: 2, time_category: "anytime", assigned_to: [], due_days: [], schedule_mode: "specific_days", assignment_mode: "everyone" }; +} + +function rankWith(completedAt) { + const { card, delays } = cardWithTimers(); + const child = { id: "kid1", name: "Mia", chore_order: [] }; + card.hass = { + states: { [ENTITY]: { state: "ok", attributes: { + children: [child], + chores: [chore("bed", "Make bed"), chore("dog", "Walk dog")], + todays_completions: [{ chore_id: "bed", child_id: "kid1", approved: true, completed_at: completedAt }], + chore_availability: {}, + } } }, + }; + const order = card._availableFirst([chore("bed", "Make bed"), chore("dog", "Walk dog")], child).map((c) => c.id); + return { order, delays }; +} + +test("a completion stamped in the future settles within the settle window", () => { + // Ninety seconds ahead keeps it "today" whenever the suite runs (bar the + // last ninety seconds before midnight) while still being in the future. + const later = new Date(Date.now() + 90 * 1000).toISOString(); + const { order, delays } = rankWith(later); + assert.deepEqual(order, ["bed", "dog"], "the just-ticked row stays where it was"); + assert.ok(delays.length > 0, "a settle timer is set"); + const settle = 8000 + 50; + assert.ok(Math.max(...delays) <= settle, `no timer longer than the settle window (got ${Math.max(...delays)} ms)`); +}); + +test("a completion ticked just now still holds its place for the settle window", () => { + const { order, delays } = rankWith(new Date().toISOString()); + assert.deepEqual(order, ["bed", "dog"]); + assert.ok(delays.some((d) => d > 7000 && d <= 8050), "the settle timer runs for about eight seconds"); +}); + +test("an old completion drops to the bottom with no timer", () => { + const { order, delays } = rankWith(new Date(Date.now() - 60 * 1000).toISOString()); + assert.deepEqual(order, ["dog", "bed"]); + assert.equal(delays.length, 0); +}); diff --git a/tests/frontend/taskmate-kid-tour.test.cjs b/tests/frontend/taskmate-kid-tour.test.cjs index 522e6a32..3dd81dba 100644 --- a/tests/frontend/taskmate-kid-tour.test.cjs +++ b/tests/frontend/taskmate-kid-tour.test.cjs @@ -374,11 +374,16 @@ const LOCKED_CHORES = [ { id: "walk", name: "Walk dog", points: 3, time_category: "anytime", assigned_to: [], due_days: [], schedule_mode: "specific_days", assignment_mode: "everyone" }, ]; // A completion stamped earlier today (local time), so "done today" checks hold -// whatever day the suite runs on. +// whatever day the suite runs on. 08:00, or a minute ago when the suite runs +// before 08:00: a stamp in the future would keep the card's settle timer +// (_availableFirst) waiting until then, and the test file would never exit. const TODAY_8AM = (() => { - const d = new Date(); + const eight = new Date(); + eight.setHours(8, 0, 0, 0); + const minuteAgo = new Date(Date.now() - 60 * 1000); + const d = eight <= minuteAgo || minuteAgo.getDate() !== eight.getDate() ? eight : minuteAgo; const p = (n) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T08:00:00`; + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:00`; })(); const BED_DONE = [{ chore_id: "bed", child_id: "kid1", approved: true, completed_at: TODAY_8AM }]; diff --git a/tests/frontend/taskmate-panel-setup-wizard.test.cjs b/tests/frontend/taskmate-panel-setup-wizard.test.cjs index 079315da..c53cd569 100644 --- a/tests/frontend/taskmate-panel-setup-wizard.test.cjs +++ b/tests/frontend/taskmate-panel-setup-wizard.test.cjs @@ -54,11 +54,19 @@ const TEMPLATES = [ { id: "custom_pack", name: "Ours", builtin: false, chores: [{ name: "Custom", points: 1 }] }, ]; +// The panel reads a birthday as a local calendar date, so the fixtures must be +// local dates too: toISOString() gives the UTC date, which is a day off when +// the suite runs east of UTC late in the evening. +function localIso(d) { + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} + function age(years, monthsAgo = 1) { const d = new Date(); d.setMonth(d.getMonth() - monthsAgo); d.setFullYear(d.getFullYear() - years); - return d.toISOString().slice(0, 10); + return localIso(d); } function panel(state = {}) { @@ -108,7 +116,7 @@ test("a birthday gives an age that only goes up on the day", () => { const d = new Date(); d.setDate(d.getDate() + 1); d.setFullYear(d.getFullYear() - 9); - assert.equal(p._wizardAgeOf({ mode: "bday", bday: d.toISOString().slice(0, 10) }), 8); + assert.equal(p._wizardAgeOf({ mode: "bday", bday: localIso(d) }), 8); assert.equal(p._wizardAgeOf({ mode: "age", age: 6 }), 6); assert.equal(p._wizardAgeOf({ mode: "none", band: "6_8" }), null); }); @@ -224,7 +232,7 @@ test("a birthday in the future is refused", async () => { const p = await opened({}); const d = new Date(); d.setDate(d.getDate() + 3); - Object.assign(p._wz.form, { name: "Isla", mode: "bday", bday: d.toISOString().slice(0, 10) }); + Object.assign(p._wz.form, { name: "Isla", mode: "bday", bday: localIso(d) }); assert.equal(p._wizardAddKid(), false); assert.equal(p._wz.kids.length, 0); assert.equal(p._toasts[0][0], "err");