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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **PWA Update Notification** (#222)
- Changed `vite.config.ts` PWA plugin from `registerType: 'autoUpdate'` to `registerType: 'prompt'`
- `useServiceWorkerUpdate` hook for detecting and managing PWA updates
- `needRefresh` state indicates when new version is available
- `offlineReady` state for offline capability
- `updateServiceWorker()` method to trigger update and reload
- `close()` method to dismiss update prompt (with 1-hour snooze - prompt will reappear after 1 hour if update is still available)
- Automatic hourly update checks via Service Worker registration
- Comprehensive error handling and logging
- `UpdatePrompt` component with Catalyst Design System
- Fixed bottom-right notification when update is available
- "Update" button to apply new version immediately
- "Later" button to dismiss and continue with current version
- Accessible with ARIA attributes (role=status, aria-live=polite)
- i18n support with lingui
- Integrated into `App.tsx` for global availability
- 30 comprehensive tests (14 for hook, 16 for component)
- **Benefit:** Users are immediately informed when new PWA versions are available and can choose when to update
- Follows Gebot #1 (Qualität vor Geschwindigkeit) - Full TDD implementation with comprehensive tests

- **Integration Tests & Developer Documentation for httpOnly Cookie Authentication** (#212, Part of Epic #208) - **CURRENT PR**
- `tests/integration/auth/cookieAuth.test.ts`: Complete integration tests for cookie-based authentication flow
- Login flow with CSRF token and httpOnly cookies
Expand Down
79 changes: 78 additions & 1 deletion PWA_PHASE3_TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,86 @@ This document describes how to test the new PWA Phase 3 features locally.

---

## 🔄 Feature 0 - PWA Update Notifications

### PWA Update Components

- Service Worker update detection with `registerType: 'prompt'`
- `useServiceWorkerUpdate` hook for managing updates
- `UpdatePrompt` component with Catalyst Design System
- Automatic hourly update checks
- User-controlled update installation

### How to Test PWA Updates

#### Testing with Production Build

1. **Build the app**

```bash
cd /home/user/code/SecPal/frontend
npm run build
npm run preview
# Opens on: http://localhost:4173
```

2. **Open app in browser**
- Chrome/Edge recommended for testing
- Open <http://localhost:4173>

3. **Simulate a code change**
- Make a small change in `src/App.tsx` (e.g., change a text)
- Rebuild: `npm run build`
- Preview should auto-reload with new build

4. **Trigger update check**
- Wait for automatic check (happens every hour)
- OR manually trigger:
- DevTools → Application → Service Workers → "Update" button
- OR refresh the page

5. **Verify Update Prompt appears**
- Alert appears in bottom-right corner
- Title: "New version available"
- Description: "A new version of SecPal is ready..."
- Two buttons: "Update" and "Later"

6. **Test "Update" button**
- Click "Update"
- Page reloads automatically
- New version is active

7. **Test "Later" button**
- Make another change → rebuild
- Trigger update check again
- Click "Later"
- Prompt disappears
- App continues with current version
- New version will be offered again after 1 hour (snooze period)

#### Testing in Development

Note: Service Worker updates work differently in `npm run dev`:

- Vite's HMR (Hot Module Replacement) takes precedence
- Service Worker may not register in dev mode
- Use production build (`npm run build && npm run preview`) for realistic testing

### PWA Update Notifications - Success Criteria

- ✅ Update prompt appears when new version is detected
- ✅ "Update" button reloads page with new version
- ✅ "Later" button dismisses prompt without updating
- ✅ Prompt is accessible (ARIA attributes, keyboard navigation)
- ✅ Automatic hourly update checks work
- ✅ No automatic reload (user controls when to update)
- ✅ Prompt positioned bottom-right, non-intrusive

---

## 🔔 Feature 1 - Push Notifications

### What It Includes
### Push Notifications Overview

- Notification Permission Management
- Service Worker Push Notifications
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Link } from "./components/link";
import { Footer } from "./components/Footer";
import { OfflineIndicator } from "./components/OfflineIndicator";
import { SyncStatusIndicator } from "./components/SyncStatusIndicator";
import { UpdatePrompt } from "./components/UpdatePrompt";
import { AuthProvider } from "./contexts/AuthContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { Login } from "./pages/Login";
Expand Down Expand Up @@ -127,6 +128,7 @@ function App() {
<Footer />
<OfflineIndicator />
<SyncStatusIndicator apiBaseUrl={getApiBaseUrl()} />
<UpdatePrompt />
</div>
</BrowserRouter>
</AuthProvider>
Expand Down
232 changes: 232 additions & 0 deletions src/components/UpdatePrompt.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// SPDX-FileCopyrightText: 2025 SecPal
// SPDX-License-Identifier: AGPL-3.0-or-later

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { UpdatePrompt } from "./UpdatePrompt";
import { useServiceWorkerUpdate } from "../hooks/useServiceWorkerUpdate";

// Mock useServiceWorkerUpdate hook
vi.mock("../hooks/useServiceWorkerUpdate");

describe("UpdatePrompt", () => {
const mockUpdateServiceWorker = vi.fn();
const mockClose = vi.fn();

beforeEach(() => {
vi.clearAllMocks();

// Setup i18n with English locale
i18n.load("en", {});
i18n.activate("en");

// Default mock: no update available
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: false,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});
});

function renderWithI18n(component: React.ReactElement) {
return render(<I18nProvider i18n={i18n}>{component}</I18nProvider>);
}

describe("Visibility", () => {
it("should not render when needRefresh is false", () => {
const { container } = renderWithI18n(<UpdatePrompt />);
expect(container.firstChild).toBeNull();
});

it("should render when needRefresh is true", () => {
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});

renderWithI18n(<UpdatePrompt />);

expect(screen.getByText("New version available")).toBeInTheDocument();
});
});

describe("Content", () => {
beforeEach(() => {
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});
});

it("should display title", () => {
renderWithI18n(<UpdatePrompt />);
expect(screen.getByText("New version available")).toBeInTheDocument();
});

it("should display description", () => {
renderWithI18n(<UpdatePrompt />);
expect(
screen.getByText(/A new version of SecPal is ready/i)
).toBeInTheDocument();
});

it("should display Update button", () => {
renderWithI18n(<UpdatePrompt />);
expect(screen.getByText("Update")).toBeInTheDocument();
});

it("should display Later button", () => {
renderWithI18n(<UpdatePrompt />);
expect(screen.getByText("Later")).toBeInTheDocument();
});
});

describe("User Interactions", () => {
beforeEach(() => {
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});
});

it("should call updateServiceWorker when Update button is clicked", async () => {
const user = userEvent.setup();
renderWithI18n(<UpdatePrompt />);

const updateButton = screen.getByText("Update");
await user.click(updateButton);

expect(mockUpdateServiceWorker).toHaveBeenCalledTimes(1);
});

it("should call close when Later button is clicked", async () => {
const user = userEvent.setup();
renderWithI18n(<UpdatePrompt />);

const laterButton = screen.getByText("Later");
await user.click(laterButton);

expect(mockClose).toHaveBeenCalledTimes(1);
});

it("should not call updateServiceWorker when Later is clicked", async () => {
const user = userEvent.setup();
renderWithI18n(<UpdatePrompt />);

const laterButton = screen.getByText("Later");
await user.click(laterButton);

expect(mockUpdateServiceWorker).not.toHaveBeenCalled();
});
});

describe("Accessibility", () => {
beforeEach(() => {
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});
});

it("should have role=status for screen readers", () => {
renderWithI18n(<UpdatePrompt />);
expect(screen.getByRole("status")).toBeInTheDocument();
});

it("should have aria-live=polite", () => {
renderWithI18n(<UpdatePrompt />);
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-live", "polite");
});

it("should have aria-atomic=true", () => {
renderWithI18n(<UpdatePrompt />);
const status = screen.getByRole("status");
expect(status).toHaveAttribute("aria-atomic", "true");
});
});

describe("Positioning", () => {
beforeEach(() => {
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});
});

it("should be fixed at bottom-right corner", () => {
const { container } = renderWithI18n(<UpdatePrompt />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass("fixed");
expect(wrapper).toHaveClass("bottom-4");
expect(wrapper).toHaveClass("right-4");
});

it("should have high z-index to overlay other content", () => {
const { container } = renderWithI18n(<UpdatePrompt />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass("z-50");
});

it("should have max-width constraint", () => {
const { container } = renderWithI18n(<UpdatePrompt />);
const wrapper = container.firstChild as HTMLElement;

expect(wrapper).toHaveClass("max-w-md");
});
});

describe("State Changes", () => {
it("should hide when needRefresh changes to false", () => {
const { rerender } = renderWithI18n(<UpdatePrompt />);

// Initially visible
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: true,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});

rerender(
<I18nProvider i18n={i18n}>
<UpdatePrompt />
</I18nProvider>
);
expect(screen.getByText("New version available")).toBeInTheDocument();

// Change to hidden
vi.mocked(useServiceWorkerUpdate).mockReturnValue({
needRefresh: false,
offlineReady: false,
updateServiceWorker: mockUpdateServiceWorker,
close: mockClose,
});

rerender(
<I18nProvider i18n={i18n}>
<UpdatePrompt />
</I18nProvider>
);
expect(
screen.queryByText("New version available")
).not.toBeInTheDocument();
});
});
});
Loading