From a55b250ca2bff44170c8c26640d16927df91f88e Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 17:02:23 +0000 Subject: chore: autocommit uncommitted changes --- web/test/task-panel-summary.test.mjs | 143 +++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 web/test/task-panel-summary.test.mjs (limited to 'web/test') diff --git a/web/test/task-panel-summary.test.mjs b/web/test/task-panel-summary.test.mjs new file mode 100644 index 0000000..63dd483 --- /dev/null +++ b/web/test/task-panel-summary.test.mjs @@ -0,0 +1,143 @@ +// task-panel-summary.test.mjs — verifies task summary renders exactly once in panel. +// +// Run with: node --test web/test/task-panel-summary.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Minimal DOM mock ────────────────────────────────────────────────────────── + +function makeMockDOM() { + const elements = {}; + + function createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + innerHTML: '', + hidden: false, + children: [], + _listeners: {}, + appendChild(child) { this.children.push(child); return child; }, + prepend(...nodes) { this.children.unshift(...nodes); }, + append(...nodes) { nodes.forEach(n => this.children.push(n)); }, + querySelector(sel) { + const cls = sel.startsWith('.') ? sel.slice(1) : null; + function search(el) { + if (cls && el.className && el.className.split(' ').includes(cls)) return el; + for (const c of el.children || []) { + const found = search(c); + if (found) return found; + } + return null; + } + return search(this); + }, + querySelectorAll(sel) { + const cls = sel.startsWith('.') ? sel.slice(1) : null; + const results = []; + function search(el) { + if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el); + for (const c of el.children || []) search(c); + } + search(this); + return results; + }, + addEventListener(ev, fn) {}, + }; + return el; + } + + // Named panel elements referenced by getElementById + const panelTitle = createElement('h2'); + const panelContent = createElement('div'); + elements['task-panel-title'] = panelTitle; + elements['task-panel-content'] = panelContent; + + const doc = { + createElement, + getElementById(id) { return elements[id] || null; }, + }; + + return { doc, panelContent }; +} + +// ── Import renderTaskPanel ──────────────────────────────────────────────────── + +import { renderTaskPanel } from '../app.js'; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('renderTaskPanel summary rendering', () => { + it('renders task summary exactly once for a COMPLETED task', () => { + const { doc, panelContent } = makeMockDOM(); + + // Must set global document before calling renderTaskPanel + global.document = doc; + + const task = { + id: 'task-1', + name: 'Fix the bug', + state: 'COMPLETED', + summary: 'Resolved the nil pointer in the payment handler.', + priority: 'normal', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:05:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + // Count all elements with class 'task-summary' or 'task-summary-text' + const summaryEls = panelContent.querySelectorAll('.task-summary'); + const summaryTextEls = panelContent.querySelectorAll('.task-summary-text'); + + const total = summaryEls.length + summaryTextEls.length; + assert.equal(total, 1, `Expected exactly 1 summary element, got ${total} (task-summary: ${summaryEls.length}, task-summary-text: ${summaryTextEls.length})`); + }); + + it('uses task-summary class (not task-summary-text) for good contrast', () => { + const { doc, panelContent } = makeMockDOM(); + global.document = doc; + + const task = { + id: 'task-2', + name: 'Another task', + state: 'COMPLETED', + summary: 'All done.', + priority: 'high', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:05:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + const summaryEls = panelContent.querySelectorAll('.task-summary'); + assert.equal(summaryEls.length, 1, 'Expected .task-summary element'); + assert.equal(summaryEls[0].textContent, task.summary); + }); + + it('renders no summary section when task has no summary', () => { + const { doc, panelContent } = makeMockDOM(); + global.document = doc; + + const task = { + id: 'task-3', + name: 'Pending task', + state: 'PENDING', + summary: null, + priority: 'normal', + created_at: '2026-03-17T10:00:00Z', + updated_at: '2026-03-17T10:00:00Z', + tags: [], + }; + + renderTaskPanel(task, []); + + const summaryEls = panelContent.querySelectorAll('.task-summary'); + const summaryTextEls = panelContent.querySelectorAll('.task-summary-text'); + assert.equal(summaryEls.length + summaryTextEls.length, 0, 'Expected no summary when task.summary is null'); + }); +}); -- cgit v1.2.3 From b5df9cadd7b8a275b8688ee8fb957142536fd26a Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 18:02:22 +0000 Subject: feat: persist active main tab to localStorage On tab click, store the tab name under 'activeMainTab' in localStorage. On DOMContentLoaded, restore the previously active tab instead of always defaulting to 'queue'. Exported getActiveMainTab/setActiveMainTab for testability, following the same pattern as getTaskFilterTab/setTaskFilterTab. Tests: web/test/tab-persistence.test.mjs (6 tests, all green). Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 12 +++++++- web/test/tab-persistence.test.mjs | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 web/test/tab-persistence.test.mjs (limited to 'web/test') diff --git a/web/app.js b/web/app.js index 6070d2c..9a5a460 100644 --- a/web/app.js +++ b/web/app.js @@ -407,6 +407,14 @@ export function setTaskFilterTab(tab) { localStorage.setItem('taskFilterTab', tab); } +export function getActiveMainTab() { + return localStorage.getItem('activeMainTab') ?? 'queue'; +} + +export function setActiveMainTab(tab) { + localStorage.setItem('activeMainTab', tab); +} + // ── Tab badge counts ─────────────────────────────────────────────────────────── /** @@ -2721,6 +2729,8 @@ async function renderDropsPanel() { // ── Tab switching ───────────────────────────────────────────────────────────── function switchTab(name) { + setActiveMainTab(name); + // Update tab button active state document.querySelectorAll('.tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === name); @@ -2746,7 +2756,7 @@ if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded handleStartNextTask(this); }); - switchTab('queue'); + switchTab(getActiveMainTab()); startPolling(); connectWebSocket(); diff --git a/web/test/tab-persistence.test.mjs b/web/test/tab-persistence.test.mjs new file mode 100644 index 0000000..9311453 --- /dev/null +++ b/web/test/tab-persistence.test.mjs @@ -0,0 +1,58 @@ +// tab-persistence.test.mjs — TDD tests for main-tab localStorage persistence +// +// Run with: node --test web/test/tab-persistence.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── localStorage mock ────────────────────────────────────────────────────────── +// Must be set up before importing app.js so the module sees the global. +const store = new Map(); +globalThis.localStorage = { + getItem: (k) => store.has(k) ? store.get(k) : null, + setItem: (k, v) => store.set(k, String(v)), + removeItem: (k) => store.delete(k), + clear: () => store.clear(), +}; + +import { getActiveMainTab, setActiveMainTab } from '../app.js'; + +describe('getActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('returns "queue" when localStorage has no stored value', () => { + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('returns the tab name stored by setActiveMainTab', () => { + setActiveMainTab('settings'); + assert.equal(getActiveMainTab(), 'settings'); + }); + + it('returns "queue" after localStorage value is removed', () => { + setActiveMainTab('stats'); + localStorage.removeItem('activeMainTab'); + assert.equal(getActiveMainTab(), 'queue'); + }); + + it('reflects the most recent setActiveMainTab call', () => { + setActiveMainTab('stats'); + setActiveMainTab('running'); + assert.equal(getActiveMainTab(), 'running'); + }); +}); + +describe('setActiveMainTab', () => { + beforeEach(() => store.clear()); + + it('writes the tab name to localStorage under key "activeMainTab"', () => { + setActiveMainTab('drops'); + assert.equal(localStorage.getItem('activeMainTab'), 'drops'); + }); + + it('overwrites a previously stored tab', () => { + setActiveMainTab('queue'); + setActiveMainTab('interrupted'); + assert.equal(localStorage.getItem('activeMainTab'), 'interrupted'); + }); +}); -- cgit v1.2.3 From 1d550c1196ea836e0a0f798ba0127c1086f5f963 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Tue, 17 Mar 2026 08:04:52 +0000 Subject: fix: unsubscribe stale push subscription before re-subscribing When the VAPID key changes (e.g. after the key-swap fix), the browser's cached PushSubscription was created with the old key. Calling PushManager.subscribe() with a different applicationServerKey then throws "The provided applicationServerKey is not valid". Fix by calling getSubscription()/unsubscribe() before subscribe() so any stale subscription is cleared. Adds web test covering both the stale and fresh subscription paths. Co-Authored-By: Claude Sonnet 4.6 --- web/app.js | 8 +++++ web/test/enable-notifications.test.mjs | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 web/test/enable-notifications.test.mjs (limited to 'web/test') diff --git a/web/app.js b/web/app.js index 9a5a460..77a5d19 100644 --- a/web/app.js +++ b/web/app.js @@ -2646,6 +2646,14 @@ async function enableNotifications(btn) { await registerServiceWorker(); const registration = await navigator.serviceWorker.ready; + // Unsubscribe any stale subscription (e.g. from a VAPID key rotation). + // PushManager.subscribe() throws "applicationServerKey is not valid" if the + // existing subscription was created with a different key. + const existingSub = await registration.pushManager.getSubscription(); + if (existingSub) { + await existingSub.unsubscribe(); + } + // Subscribe via PushManager. const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, diff --git a/web/test/enable-notifications.test.mjs b/web/test/enable-notifications.test.mjs new file mode 100644 index 0000000..c8afdd3 --- /dev/null +++ b/web/test/enable-notifications.test.mjs @@ -0,0 +1,64 @@ +// enable-notifications.test.mjs — Tests for the enableNotifications subscription flow. +// +// Run with: node --test web/test/enable-notifications.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Logic under test ────────────────────────────────────────────────────────── +// +// When subscribing to push notifications, any existing stale subscription +// (e.g. from before a VAPID key rotation) must be unsubscribed first. +// Otherwise the browser rejects subscribe() with "applicationServerKey is not valid". + +/** + * Extracted subscription logic (mirrors enableNotifications in app.js). + * Returns the new subscription endpoint, or throws on error. + */ +async function subscribeWithUnsubscribeStale(pushManager, applicationServerKey) { + // Clear any stale existing subscription. + const existing = await pushManager.getSubscription(); + if (existing) { + await existing.unsubscribe(); + } + const sub = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + return sub; +} + +describe('subscribeWithUnsubscribeStale', () => { + it('unsubscribes existing subscription before subscribing', async () => { + let unsubscribeCalled = false; + const existingSub = { + unsubscribe: async () => { unsubscribeCalled = true; }, + }; + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => existingSub, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(unsubscribeCalled, true, 'existing subscription should have been unsubscribed'); + assert.equal(subscribeCalled, true, 'new subscription should have been created'); + }); + + it('subscribes normally when no existing subscription', async () => { + let subscribeCalled = false; + const pushManager = { + getSubscription: async () => null, + subscribe: async (opts) => { + subscribeCalled = true; + return { endpoint: 'https://push.example.com/sub/new', toJSON: () => ({}) }; + }, + }; + + const sub = await subscribeWithUnsubscribeStale(pushManager, new Uint8Array([4, 1, 2])); + + assert.equal(subscribeCalled, true, 'subscribe should have been called'); + assert.ok(sub, 'subscription object should be returned'); + }); +}); -- cgit v1.2.3