Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

All notable changes to PWA Template will be documented in this file. This project adheres to a manual release process; update both this file and `assets/changelog.json` when shipping new versions so the in-app update summary stays accurate.

## [NEXT_VERSION] - [NEXT_DATE]

- Expand automated test coverage for additional edge cases so branch coverage now exceeds the 80% minimum target.

## 1.1.39 - 2026-03-18

- Add extra automated test scenarios for fallback states and navigation actions so reliability checks catch more edge cases.
Expand Down
7 changes: 7 additions & 0 deletions assets/changelog.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
[
{
"version": "[NEXT_VERSION]",
"date": "[NEXT_DATE]",
"changes": [
"Increase automated branch coverage above 80% by adding tests for more edge-case app behavior."
]
},
{
"version": "1.1.39",
"date": "2026-03-18",
Expand Down
244 changes: 244 additions & 0 deletions tests/app.coverage-extra.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('app coverage additions', () => {
<button id="menu-toggle" type="button"></button>
<button id="clearDataButton" type="button" data-action="clear-data">Clear</button>
<button id="goSettingsButton" type="button" data-action="go-settings">Go settings</button>
<button id="updateButton" type="button" data-action="update-app">Check updates</button>
<aside id="sidebar" class="-translate-x-full">
<button class="nav-btn" data-target="welcome"></button>
<button class="nav-btn" data-target="settings"></button>
Expand All @@ -39,6 +40,7 @@ describe('app coverage additions', () => {
</main>
<div id="modal" class="modal-hidden" aria-hidden="true">
<div id="modal-body"></div>
<button type="button" data-action="close-modal" class="hidden">X</button>
</div>
<section id="changelogCard">
<div data-changelog-list></div>
Expand Down Expand Up @@ -133,4 +135,246 @@ describe('app coverage additions', () => {
expect(document.querySelector('[data-changelog-empty]').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-app-version]').textContent).toBe('0.0.0');
});

test('uses alert fallback when update check cannot resolve a registration', async () => {
const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {});
document.getElementById('modal').remove();
Object.defineProperty(window.navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: jest.fn(async () => null),
ready: Promise.reject(new Error('offline')),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
register: jest.fn(async () => ({ unregister: jest.fn() })),
},
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
document.getElementById('updateButton').click();
await new Promise((resolve) => setTimeout(resolve, 0));

expect(alertSpy).toHaveBeenCalledWith(
expect.stringContaining("couldn't reach the update service"),
);
});

test('handles malformed changelog/version responses and close-modal action', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
global.fetch = jest.fn(async (url) => {
if (String(url).includes('changelog')) {
return { ok: true, json: async () => [{ version: null, date: 'not-a-date', changes: ['x'] }] };
}
if (String(url).includes('version')) {
return { ok: true, json: async () => ({ version: 123 }) };
}
return { ok: false, json: async () => ({}) };
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(document.querySelector('[data-changelog-list]').textContent).toContain('Version null');
expect(document.querySelector('[data-app-version]').textContent).toBe('0.0.0');

document.getElementById('updateButton').click();
await new Promise((resolve) => setTimeout(resolve, 0));
document.querySelector('[data-action="close-modal"]').click();
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(true);
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('Unable to load changelog'));
});

test('falls back safely when collapsed card state in storage is invalid JSON', () => {
localStorage.setItem('collapsedCards', '{invalid json');

expect(() => {
require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
}).not.toThrow();

const card = document.querySelector('[data-collapsible-id="example-card"]');
expect(card.classList.contains('collapsed')).toBe(false);
});

test('keeps sidebar closed for unknown nav target and leaves clear-data cancellable', () => {
require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

document.querySelector('#sidebar .nav-btn').dataset.target = 'missing';
document.querySelector('#sidebar .nav-btn').click();
expect(document.getElementById('welcome').classList.contains('active')).toBe(true);

document.getElementById('clearDataButton').click();
const cancelButton = Array.from(document.querySelectorAll('#modal button')).find(
(button) => button.textContent === 'Cancel',
);
cancelButton.click();
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(true);
});

test('closes modal from overlay click and escape key while preserving stored welcome fallback', async () => {
localStorage.setItem('activeView', 'welcome');
localStorage.setItem('welcomeDisabled', '1');

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

expect(document.getElementById('settings').classList.contains('active')).toBe(true);
document.getElementById('updateButton').click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(false);

document.getElementById('modal').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(true);

document.getElementById('updateButton').click();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(true);
});

test('navigates with brand home according to welcome visibility preference', () => {
require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

document.getElementById('goSettingsButton').click();
expect(document.getElementById('settings').classList.contains('active')).toBe(true);

document.getElementById('brandHome').click();
expect(document.getElementById('welcome').classList.contains('active')).toBe(true);

document.getElementById('welcomeToggle').checked = false;
document.getElementById('welcomeToggle').dispatchEvent(new Event('change', { bubbles: true }));
document.getElementById('brandHome').click();
expect(document.getElementById('settings').classList.contains('active')).toBe(true);
});

test('ignores unsupported actions and survives storage read failures', () => {
const getItemSpy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('storage disabled');
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

const unknownAction = document.createElement('button');
unknownAction.dataset.action = 'do-nothing';
document.body.appendChild(unknownAction);
unknownAction.click();

expect(document.getElementById('welcome').classList.contains('active')).toBe(true);
expect(getItemSpy).toHaveBeenCalled();
});

test('dismisses confirmation modal with Escape and unhides dedicated close button', () => {
require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

document.getElementById('clearDataButton').click();
expect(document.querySelector('#modal [data-action="close-modal"]').classList.contains('hidden')).toBe(
false,
);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(document.getElementById('modal').classList.contains('modal-hidden')).toBe(true);
});

test('handles failed changelog and version fetches gracefully', async () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
global.fetch = jest.fn(async () => {
throw new Error('network down');
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
await new Promise((resolve) => setTimeout(resolve, 0));

expect(document.querySelector('[data-changelog-error]').classList.contains('hidden')).toBe(false);
expect(document.querySelector('[data-app-version]').textContent).toBe('0.0.0');
expect(errorSpy).toHaveBeenCalled();
});

test('sorts changelog versions using numeric comparison', async () => {
global.fetch = jest.fn(async (url) => {
if (String(url).includes('changelog')) {
return {
ok: true,
json: async () => [
{ version: '1.2.0', date: '2026-01-01', changes: [] },
{ version: '1.10.0', date: '2026-01-02', changes: [] },
],
};
}
return { ok: true, json: async () => ({ version: '1.10.0' }) };
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
await new Promise((resolve) => setTimeout(resolve, 0));

const headings = Array.from(document.querySelectorAll('[data-changelog-list] p'));
expect(headings[0].textContent).toContain('1.10.0');
});

test('runs confirmed clear-data flow without crashing test environment', async () => {
window.location.reload = jest.fn();
localStorage.setItem('themeChoice', 'glass');
sessionStorage.setItem('scratch', '1');

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

document.getElementById('clearDataButton').click();
const confirmButton = Array.from(document.querySelectorAll('#modal button')).find(
(button) => button.textContent === 'Confirm',
);
confirmButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));

expect(localStorage.getItem('themeChoice')).toBeNull();
expect(sessionStorage.getItem('scratch')).toBeNull();
});

test('swallows storage clear failures during confirmed reset flow', async () => {
window.location.reload = jest.fn();
const clearSpy = jest.spyOn(Storage.prototype, 'clear').mockImplementation(() => {
throw new Error('quota blocked');
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));

document.getElementById('clearDataButton').click();
const confirmButton = Array.from(document.querySelectorAll('#modal button')).find(
(button) => button.textContent === 'Confirm',
);
confirmButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));

expect(clearSpy).toHaveBeenCalled();
});

test('falls back when refocusing update button throws', async () => {
Object.defineProperty(window.navigator, 'serviceWorker', {
configurable: true,
value: {
getRegistration: jest.fn(async () => null),
ready: Promise.resolve(null),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
register: jest.fn(async () => ({ unregister: jest.fn() })),
},
});

require('../assets/js/app.js');
document.dispatchEvent(new Event('DOMContentLoaded'));
const updateButton = document.getElementById('updateButton');
updateButton.focus = jest.fn(() => {
throw new Error('focus blocked');
});

updateButton.click();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(updateButton.focus).toHaveBeenCalled();
});
});