Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,14 @@ jobs:
- uses: actions/checkout@v4

- name: Install pnpm
uses: pnpm/action-setup@v2
uses: pnpm/action-setup@v4
with:
version: 8
version: 10

- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24'
cache: 'pnpm'

- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v24
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"description": "[**🏆 Live Demo →**](https://ccombe.github.io/emom-timer/)",
"main": "src/js/app.js",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
Expand All @@ -22,13 +25,14 @@
},
"homepage": "https://github.com/ccombe/emom-timer#readme",
"devDependencies": {
"@types/node": "^20.19.33",
"@types/node": "^20.19.35",
"@vitest/coverage-v8": "^4.0.18",
"fake-indexeddb": "^6.2.5",
"idb": "^8.0.3",
"jsdom": "^27.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
},
"packageManager": "pnpm@10.30.2"
}
318 changes: 166 additions & 152 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
overrides:
rollup@>=4.0.0 <4.59.0: '>=4.59.0'
88 changes: 87 additions & 1 deletion src/ts/google-fit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GoogleFitService } from './google-fit.ts';

// Mock Fetch
Expand Down Expand Up @@ -30,10 +30,81 @@ describe('GoogleFitService', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// Mock Date.now() for consistent testing
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
service = new GoogleFitService();
service.accessToken = 'mock-token'; // Simulate connected
});

afterEach(() => {
vi.useRealTimers();
});

describe('constructor and token expiry', () => {
it('clears expired token on initialization', () => {
localStorage.setItem('google_fit_token', 'old-token');
localStorage.setItem('google_fit_token_expiry', (Date.now() - 1000).toString());

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
logSpy.mockRestore();
Comment on lines +50 to +53
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logSpy.mockRestore() (and similar console spies later in this file) is called after assertions; if an assertion throws, the spy won't be restored and can leak into subsequent tests. Prefer wrapping the spy usage in try/finally, or add an afterEach that calls vi.restoreAllMocks() so the original console methods are always restored.

Suggested change
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
logSpy.mockRestore();
try {
const newService = new GoogleFitService();
expect(newService.accessToken).toBeNull();
expect(localStorage.getItem('google_fit_token')).toBeNull();
} finally {
logSpy.mockRestore();
}

Copilot uses AI. Check for mistakes.
});

it('retains valid token on initialization', () => {
const future = Date.now() + 3600000;
localStorage.setItem('google_fit_token', 'valid-token');
localStorage.setItem('google_fit_token_expiry', future.toString());

const newService = new GoogleFitService();
expect(newService.accessToken).toBe('valid-token');
});
});

describe('initialize', () => {
it('does nothing if window.google is missing', () => {
const originalGoogle = window.google;
delete (window as any).google;
service.initialize();
expect(service.tokenClient).toBeNull();
window.google = originalGoogle;
});

it('initializes token client when window.google exists', () => {
const mockInitTokenClient = vi.fn().mockReturnValue({ requestAccessToken: vi.fn() });
(window as any).google = {
accounts: {
oauth2: {
initTokenClient: mockInitTokenClient
}
}
};

service.initialize();
expect(mockInitTokenClient).toHaveBeenCalled();
expect(service.tokenClient).toBeDefined();
});
});

describe('connect', () => {
it('calls requestAccessToken if client exists', () => {
const requestMock = vi.fn();
service.tokenClient = { requestAccessToken: requestMock };
service.connect();
expect(requestMock).toHaveBeenCalled();
});

it('logs error if client does not exist', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
service.tokenClient = null;
service.connect();
expect(spy).toHaveBeenCalledWith("Google Identity Services not initialized.");
spy.mockRestore();
});
});

describe('uploadSession', () => {
it('uploads valid session data', async () => {
const session = {
Expand Down Expand Up @@ -82,8 +153,14 @@ describe('GoogleFitService', () => {
json: async () => ({ error: "Bad request" })
});

// Mock console.error to avoid noisy stderr in tests
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });

const result = await service.uploadSession({ duration: 60, interval: 60, activityType: 114 });
expect(result).toBe(false);

expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});

Expand Down Expand Up @@ -118,6 +195,15 @@ describe('GoogleFitService', () => {
expect(dates).toEqual([]);
});

it('handles fetch exceptions gracefully', async () => {
(fetch as any).mockRejectedValueOnce(new Error('Network failure'));
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
const dates = await service.fetchWorkoutHistory();
expect(dates).toEqual([]);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});

it('handles malformed/empty buckets gracefully', async () => {
const mockResponse = {
bucket: [
Expand Down
20 changes: 20 additions & 0 deletions src/ts/logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ describe('Core Logic', () => {
it('handles finished state', () => {
expect(getCurrentRound({ elapsed: 300, intervalDuration: intervalSecs, totalDuration })).toEqual({ current: 5, total: 5 });
});

it('handles very large elapsed time gracefully', () => {
expect(getCurrentRound({ elapsed: 1000, intervalDuration: intervalSecs, totalDuration })).toEqual({ current: 5, total: 5 });
});
});

describe('isFinished', () => {
Expand Down Expand Up @@ -116,6 +120,22 @@ describe('Core Logic', () => {
const result = getCountdownBeep(context);
expect(result.shouldBeep).toBe(false);
});

it('should beep for different beep codes when lastBeepId is different', () => {
// Test 3 seconds remaining (57s)
const res1 = getCountdownBeep({ elapsed: 57, intervalDuration: 60, lastBeepId: '0-2' });
expect(res1.shouldBeep).toBe(true);
if (res1.shouldBeep) {
expect(res1.frequency).toBe(440);
}

// Test 2 seconds remaining (58s)
const res2 = getCountdownBeep({ elapsed: 58, intervalDuration: 60, lastBeepId: '0-3' });
expect(res2.shouldBeep).toBe(true);
if (res2.shouldBeep) {
expect(res2.frequency).toBe(554);
}
});
});

describe('normalizeDate', () => {
Expand Down
40 changes: 38 additions & 2 deletions src/ts/storage.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { StorageService } from './storage.ts';
import 'fake-indexeddb/auto';

describe('StorageService', () => {
let storage: StorageService;

beforeEach(() => {
beforeEach(async () => {
// Clear IndexedDB to ensure test isolation
const DB_NAME = 'emom-timer-db';
const request = indexedDB.deleteDatabase(DB_NAME);
await new Promise((resolve, reject) => {
request.onsuccess = resolve;
request.onerror = reject;
Comment on lines +11 to +13
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this Promise wrapper around indexedDB.deleteDatabase, request.onerror = reject will reject with the raw event rather than the underlying request.error, which makes failures harder to diagnose. Consider rejecting with request.error (or a new Error that includes request.error?.message) so test failures are actionable.

Suggested change
await new Promise((resolve, reject) => {
request.onsuccess = resolve;
request.onerror = reject;
await new Promise<void>((resolve, reject) => {
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
const error = request.error;
if (error) {
reject(error);
} else {
reject(new Error(`IndexedDB deleteDatabase("${DB_NAME}") failed for an unknown reason.`));
}
};

Copilot uses AI. Check for mistakes.
request.onblocked = () => {
// If blocked, we might have a leak, but for tests we try to proceed
resolve(null);
};
});
storage = new StorageService();
});

afterEach(async () => {
if (storage) {
await storage.close();
}
});

it('saves and retrieves session', async () => {
const session = {
duration: 300,
Expand Down Expand Up @@ -64,4 +81,23 @@ describe('StorageService', () => {
const streak = await storage.getStreak();
expect(streak).toBe(2);
});

it('saves and loads settings', async () => {
const settings = {
intervalCount: 8,
intervalSecs: 45,
activityType: 114,
includeLocation: true,
setupComplete: true
};

await storage.saveSettings(settings);
const loaded = await storage.loadSettings();
expect(loaded).toEqual(settings);
});

it('returns undefined for non-existent settings', async () => {
const settings = await storage.loadSettings();
expect(settings).toBeUndefined();
});
});
5 changes: 5 additions & 0 deletions src/ts/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,9 @@ export class StorageService {
const dates = sessions.map(s => s.date);
return calculateStreak(dates);
}

async close(): Promise<void> {
const db = await this.dbPromise;
db.close();
}
}
Loading