diff options
Diffstat (limited to 'web/test')
| -rw-r--r-- | web/test/deployment-badge.test.mjs | 66 | ||||
| -rw-r--r-- | web/test/enable-notifications.test.mjs | 64 | ||||
| -rw-r--r-- | web/test/stories.test.mjs | 164 | ||||
| -rw-r--r-- | web/test/tab-persistence.test.mjs | 58 | ||||
| -rw-r--r-- | web/test/task-panel-summary.test.mjs | 144 |
5 files changed, 496 insertions, 0 deletions
diff --git a/web/test/deployment-badge.test.mjs b/web/test/deployment-badge.test.mjs new file mode 100644 index 0000000..438fb27 --- /dev/null +++ b/web/test/deployment-badge.test.mjs @@ -0,0 +1,66 @@ +// deployment-badge.test.mjs — Unit tests for deployment status badge. +// +// Run with: node --test web/test/deployment-badge.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderDeploymentBadge } from '../app.js'; + +function makeDoc() { + return { + createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + title: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + return el; + }, + }; +} + +describe('renderDeploymentBadge', () => { + it('returns null for null status', () => { + const el = renderDeploymentBadge(null, makeDoc()); + assert.equal(el, null); + }); + + it('returns null for undefined status', () => { + const el = renderDeploymentBadge(undefined, makeDoc()); + assert.equal(el, null); + }); + + it('returns null when includes_fix is false', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: false }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.equal(el, null); + }); + + it('returns element with deployment-badge class when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el, 'element should not be null'); + assert.ok(el.className.includes('deployment-badge'), `className should include deployment-badge, got: ${el.className}`); + }); + + it('shows "Deployed" text when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.textContent.includes('Deployed'), `expected "Deployed" in "${el.textContent}"`); + }); + + it('applies deployed class when includes_fix is true', () => { + const status = { deployed_commit: 'abc123', fix_commits: [{ hash: 'aabbcc', message: 'fix' }], includes_fix: true }; + const el = renderDeploymentBadge(status, makeDoc()); + assert.ok(el.className.includes('deployment-badge--deployed'), `className: ${el.className}`); + }); + + it('returns null for doc=null', () => { + const status = { deployed_commit: 'abc', fix_commits: [], includes_fix: true }; + const el = renderDeploymentBadge(status, null); + assert.equal(el, null); + }); +}); 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'); + }); +}); diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs new file mode 100644 index 0000000..263f202 --- /dev/null +++ b/web/test/stories.test.mjs @@ -0,0 +1,164 @@ +// stories.test.mjs — TDD tests for stories UI functions. +// +// Run with: node --test web/test/stories.test.mjs + +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { renderStoryCard, storyStatusLabel } from '../app.js'; + +// ── Minimal DOM mock ────────────────────────────────────────────────────────── + +function makeMockDoc() { + function createElement(tag) { + return { + tag, + className: '', + textContent: '', + innerHTML: '', + hidden: false, + dataset: {}, + children: [], + _listeners: {}, + style: {}, + appendChild(child) { this.children.push(child); return child; }, + prepend(...nodes) { this.children.unshift(...nodes); }, + append(...nodes) { nodes.forEach(n => this.children.push(n)); }, + addEventListener(ev, fn) { this._listeners[ev] = fn; }, + 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; + }, + }; + } + return { createElement }; +} + +function makeStory(overrides = {}) { + return { + id: 'story-1', + name: 'Add login page', + project_id: 'claudomator', + branch_name: 'story/add-login-page', + status: 'PENDING', + created_at: '2026-03-25T10:00:00Z', + updated_at: '2026-03-25T10:00:00Z', + ...overrides, + }; +} + +// ── storyStatusLabel ────────────────────────────────────────────────────────── + +describe('storyStatusLabel', () => { + it('returns human-readable label for PENDING', () => { + assert.equal(storyStatusLabel('PENDING'), 'Pending'); + }); + + it('returns human-readable label for IN_PROGRESS', () => { + assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress'); + }); + + it('returns human-readable label for SHIPPABLE', () => { + assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable'); + }); + + it('returns human-readable label for DEPLOYED', () => { + assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed'); + }); + + it('returns human-readable label for VALIDATING', () => { + assert.equal(storyStatusLabel('VALIDATING'), 'Validating'); + }); + + it('returns human-readable label for REVIEW_READY', () => { + assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready'); + }); + + it('returns human-readable label for NEEDS_FIX', () => { + assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix'); + }); + + it('falls back to the raw status for unknown values', () => { + assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE'); + }); +}); + +// ── renderStoryCard ─────────────────────────────────────────────────────────── + +describe('renderStoryCard', () => { + let doc; + beforeEach(() => { doc = makeMockDoc(); }); + + it('renders the story name', () => { + const card = renderStoryCard(makeStory(), doc); + function findText(el, text) { + if (el.textContent === text) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'Add login page'), 'card should contain story name'); + }); + + it('has story-card class', () => { + const card = renderStoryCard(makeStory(), doc); + assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class'); + }); + + it('status badge has data-status matching story status', () => { + const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc); + const badge = card.querySelector('.story-status-badge'); + assert.ok(badge, 'badge element should exist'); + assert.equal(badge.dataset.status, 'IN_PROGRESS'); + }); + + it('status badge shows human-readable label', () => { + const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc); + const badge = card.querySelector('.story-status-badge'); + assert.equal(badge.textContent, 'Review Ready'); + }); + + it('shows project_id', () => { + const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc); + function findText(el, text) { + if (el.textContent === text) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'nav'), 'card should show project_id'); + }); + + it('shows branch_name when present', () => { + const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc); + function findText(el, text) { + if (el.textContent && el.textContent.includes(text)) return true; + return (el.children || []).some(c => findText(c, text)); + } + assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name'); + }); + + it('does not show branch section when branch_name is empty', () => { + const card = renderStoryCard(makeStory({ branch_name: '' }), doc); + const branchEl = card.querySelector('.story-branch'); + assert.ok(!branchEl, 'no .story-branch element when branch is empty'); + }); + + it('card dataset.storyId is set to story id', () => { + const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc); + assert.equal(card.dataset.storyId, 'abc-123'); + }); +}); 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'); + }); +}); diff --git a/web/test/task-panel-summary.test.mjs b/web/test/task-panel-summary.test.mjs new file mode 100644 index 0000000..1777003 --- /dev/null +++ b/web/test/task-panel-summary.test.mjs @@ -0,0 +1,144 @@ +// 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: [], + dataset: {}, + _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'); + }); +}); |
