diff --git a/package-lock.json b/package-lock.json
index 2134f81..e61d980 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -223,9 +223,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -233,9 +233,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -267,13 +267,13 @@
}
},
"node_modules/@babel/parser": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
- "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.0"
+ "@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -327,19 +327,29 @@
}
},
"node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -1714,6 +1724,37 @@
}
}
},
+ "node_modules/@vitest/coverage-v8": {
+ "version": "4.1.6",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
+ "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^1.0.2",
+ "@vitest/utils": "4.1.6",
+ "ast-v8-to-istanbul": "^1.0.0",
+ "istanbul-lib-coverage": "^3.2.2",
+ "istanbul-lib-report": "^3.0.1",
+ "istanbul-reports": "^3.2.0",
+ "magicast": "^0.5.2",
+ "obug": "^2.1.1",
+ "std-env": "^4.0.0-rc.1",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@vitest/browser": "4.1.6",
+ "vitest": "4.1.6"
+ },
+ "peerDependenciesMeta": {
+ "@vitest/browser": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@vitest/expect": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
@@ -1944,6 +1985,25 @@
"node": ">=12"
}
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz",
+ "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^10.0.0"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/backend": {
"resolved": "packages/backend",
"link": true
@@ -3245,6 +3305,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -3423,6 +3490,45 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4012,6 +4118,47 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/magicast": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
+ "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@babel/types": "^7.29.0",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -5675,6 +5822,7 @@
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@vitejs/plugin-react": "^6.0.1",
+ "@vitest/coverage-v8": "^4.1.6",
"jsdom": "^27.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
diff --git a/packages/tests/frontend/Leaderboard.test.jsx b/packages/tests/frontend/Leaderboard.test.jsx
index 9980105..d72dc57 100644
--- a/packages/tests/frontend/Leaderboard.test.jsx
+++ b/packages/tests/frontend/Leaderboard.test.jsx
@@ -92,4 +92,28 @@ describe("Leaderboard", () => {
expect.objectContaining({ credentials: "include" }),
);
});
+
+ it("renders an error message when the leaderboard request fails", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(() =>
+ mockFetchResponse({ error: "Service unavailable" }, false),
+ ),
+ );
+
+ render();
+
+ expect(await screen.findByText(/service unavailable/i)).toBeInTheDocument();
+ });
+
+ it("renders nothing when the leaderboard is disabled for the current user", () => {
+ const disabledUser = {
+ totalHours: 42.5,
+ featureSettings: { leaderboardEnabled: false },
+ };
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ });
});
diff --git a/packages/tests/frontend/Timer.test.jsx b/packages/tests/frontend/Timer.test.jsx
index 17e3b8a..2e56fe4 100644
--- a/packages/tests/frontend/Timer.test.jsx
+++ b/packages/tests/frontend/Timer.test.jsx
@@ -1,15 +1,68 @@
-import { render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
+
+vi.mock("@frontend/components/MouseLock.jsx", async () => {
+ const React = await import("react");
+
+ return {
+ default: function MockMouseLock({ canvasRef, onLockChange }) {
+ return React.createElement("canvas", {
+ ref: canvasRef,
+ "data-testid": "mouse-lock-canvas",
+ onClick: () => onLockChange(true),
+ onDoubleClick: () => onLockChange(false),
+ });
+ },
+ };
+});
+
import Timer from "@frontend/components/Timer.jsx";
+beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+
+ Object.defineProperty(document, "pointerLockElement", {
+ value: null,
+ configurable: true,
+ });
+
+ document.exitPointerLock = vi.fn();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+async function startSession(container) {
+ const canvas = container.querySelector("canvas");
+
+ canvas.requestPointerLock = vi.fn().mockResolvedValue();
+
+ fireEvent.click(screen.getByRole("button", { name: /start session/i }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ fireEvent.click(canvas);
+
+ return canvas;
+}
+
+function renderTimer(props = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
describe("Timer", () => {
it("renders the initial timer, controls, and canvas", () => {
- const { container } = render(
-
-
- ,
- );
+ const { container } = renderTimer();
expect(screen.getByRole("heading", { name: "25:00" })).toBeInTheDocument();
expect(
@@ -20,4 +73,204 @@ describe("Timer", () => {
).toBeInTheDocument();
expect(container.querySelector("canvas")).toBeInTheDocument();
});
+
+ it("shows an error when pointer lock fails with SecurityError", async () => {
+ const { container } = renderTimer();
+
+ const canvas = container.querySelector("canvas");
+ const button = screen.getByRole("button", { name: /start session/i });
+
+ vi.spyOn(canvas, "requestPointerLock").mockRejectedValueOnce(
+ Object.assign(new Error("Denied"), { name: "SecurityError" }),
+ );
+
+ fireEvent.click(button);
+
+ expect(
+ await screen.findByText(/cannot start the session that quick :\//i),
+ ).toBeInTheDocument();
+ });
+
+ it("changes the session button when the lock becomes active and stops the session", async () => {
+ const { container } = renderTimer();
+
+ const canvas = await startSession(container);
+
+ expect(
+ screen.getByRole("button", { name: /stop session/i }),
+ ).toBeInTheDocument();
+
+ Object.defineProperty(document, "pointerLockElement", {
+ value: canvas,
+ configurable: true,
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /stop session/i }));
+
+ expect(document.exitPointerLock).toHaveBeenCalled();
+ expect(
+ screen.getByRole("button", { name: /start session/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("clears pointer lock SecurityError message after five seconds", async () => {
+ vi.useFakeTimers();
+
+ const { container } = renderTimer();
+
+ const canvas = container.querySelector("canvas");
+
+ canvas.requestPointerLock = vi.fn().mockRejectedValueOnce(
+ Object.assign(new Error("Denied"), { name: "SecurityError" }),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /start session/i }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(
+ screen.getByText(/cannot start the session that quick :\//i),
+ ).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(
+ screen.queryByText(/cannot start the session that quick :\//i),
+ ).not.toBeInTheDocument();
+});
+
+it("falls back to normal pointer lock when unadjusted movement is unsupported", async () => {
+ const { container } = renderTimer();
+
+ const canvas = container.querySelector("canvas");
+
+ canvas.requestPointerLock = vi
+ .fn()
+ .mockRejectedValueOnce(
+ Object.assign(new Error("Unsupported"), { name: "NotSupportedError" }),
+ )
+ .mockResolvedValueOnce();
+
+ fireEvent.click(screen.getByRole("button", { name: /start session/i }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(canvas.requestPointerLock).toHaveBeenCalledTimes(2);
+ expect(canvas.requestPointerLock).toHaveBeenNthCalledWith(1, {
+ unadjustedMovement: true,
+ });
+ expect(canvas.requestPointerLock).toHaveBeenNthCalledWith(2);
+});
+
+it("logs unexpected pointer lock errors", async () => {
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ const { container } = renderTimer();
+
+ const canvas = container.querySelector("canvas");
+ const error = Object.assign(new Error("Unexpected"), {
+ name: "UnknownError",
+ });
+
+ canvas.requestPointerLock = vi.fn().mockRejectedValueOnce(error);
+
+ fireEvent.click(screen.getByRole("button", { name: /start session/i }));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(consoleError).toHaveBeenCalledWith(error);
+ expect(
+ screen.queryByText(/cannot start the session that quick :\//i),
+ ).not.toBeInTheDocument();
+});
+
+it("counts down while running", async () => {
+ vi.useFakeTimers();
+
+ const { container } = renderTimer();
+
+ await startSession(container);
+
+ expect(
+ screen.getByRole("button", { name: /stop session/i }),
+ ).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(screen.getByRole("heading", { name: "24:59" })).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.getByRole("heading", { name: "24:57" })).toBeInTheDocument();
+});
+
+it("saves completed focus time and switches to a short break", async () => {
+ vi.useFakeTimers();
+
+ fetch.mockResolvedValueOnce({ ok: true });
+
+ const { container } = renderTimer({ userId: "user-123" });
+
+ await startSession(container);
+
+ await act(async () => {
+ vi.advanceTimersByTime(1500 * 1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+
+ expect(fetch).toHaveBeenCalledWith(
+ expect.stringContaining("/api/users/user-123/study-time"),
+ expect.objectContaining({
+ method: "PATCH",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ hours: 1500 / 3600 }),
+ }),
+ );
+
+ expect(
+ screen.getByText(/session complete! press the button to continue/i),
+ ).toBeInTheDocument();
+
+ expect(screen.getByRole("heading", { name: "05:00" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /start session/i }),
+ ).toBeInTheDocument();
+});
+
+it("shows a network error when saving completed study time fails", async () => {
+ vi.useFakeTimers();
+
+ fetch.mockResolvedValueOnce({ ok: false });
+
+ const { container } = renderTimer();
+
+ await startSession(container);
+
+ await act(async () => {
+ vi.advanceTimersByTime(1500 * 1000);
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(
+ screen.getByText(/network error: failed to update study time/i),
+ ).toBeInTheDocument();
+});
+
});
diff --git a/packages/tests/package.json b/packages/tests/package.json
index 2392b2c..b6cea2f 100644
--- a/packages/tests/package.json
+++ b/packages/tests/package.json
@@ -4,12 +4,13 @@
"version": "1.0.0",
"type": "module",
"scripts": {
- "test": "vitest run"
+ "test": "vitest run --coverage"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@vitejs/plugin-react": "^6.0.1",
+ "@vitest/coverage-v8": "^4.1.6",
"jsdom": "^27.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
diff --git a/packages/tests/vitest.config.js b/packages/tests/vitest.config.js
index 0612ea4..435e904 100644
--- a/packages/tests/vitest.config.js
+++ b/packages/tests/vitest.config.js
@@ -18,5 +18,23 @@ export default defineConfig({
environment: "jsdom",
globals: true,
setupFiles: ["./setupTests.js"],
+ coverage: {
+ provider: 'v8',
+
+ // collect coverage for files outside the tests package
+ allowExternal: true,
+
+ // Only collect frontend source coverage
+ include: ["frontend/src/**/*.{js,jsx}"],
+
+ // Exclude frontend entry/config files and the entire backend
+ exclude: [
+ "frontend/src/main.jsx",
+ "frontend/src/vite-env.d.ts",
+ "backend/**",
+ "backend/**/*",
+ ],
+
+ },
},
});