summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
Diffstat (limited to 'web/test')
-rw-r--r--web/test/deployment-badge.test.mjs66
-rw-r--r--web/test/enable-notifications.test.mjs64
-rw-r--r--web/test/stories.test.mjs164
-rw-r--r--web/test/tab-persistence.test.mjs58
-rw-r--r--web/test/task-panel-summary.test.mjs144
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');
+ });
+});