- Implement tests for push subscription registration, chore and reward notifications, and deep-link navigation. - Cover scenarios for approving and denying chores and rewards, including token validation for digest actions. - Introduce a mock strategy for service worker push delivery to facilitate manual testing. - Ensure isolated test setups with appropriate cleanup after tests.
28 KiB
Parent Notifications E2E Tests
Application Overview
The parent notifications feature adds two channels for alerting parents when a child performs an action requiring their attention:
-
Web Push Notifications — When a child confirms a chore is complete, or requests an affordable reward, the parent receives a native browser push notification via a Service Worker. The payload contains
child_id,entity_id, andentity_type. Supporting browsers (Chrome desktop) show "Approve" and "Deny" action buttons; clicking an action button calls the matching backend API (approve-chore,reject-chore,trigger-reward, ordeny-reward-request) without opening the app. Tapping the notification body opens or focuses the app and navigates to/parent/:id?scrollTo=<entityId>&entityType=<chore|reward>. -
Daily Email Digest — At 9 pm in the parent's local timezone, a HTML digest email is sent for any still-pending items (only if at least one exists). Each item has three links: View (ParentView deep link), Approve (
GET /api/digest-action/<token>), and Deny (GET /api/digest-action/<token>). Tokens are HMAC-SHA256 signed, valid for 24 hours, and single-use. On success the endpoint executes the action and 302-redirects to the ParentView deep link; on error (expired, already-used, tampered) it returns a 400 HTML page with no redirect.
In-app notification view — /parent/notifications lists all pending confirmations via GET /api/pending-confirmations. Clicking an item navigates to /parent/:id?scrollTo=<entityId>&entityType=<type>. Approval and rejection happen via ParentView:
- Pending chores: card shows a
.chore-stamp.pending-stamp"PENDING" badge; clicking the card opensChoreApproveDialogwhich has Approve and Reject buttons. - Pending rewards: card shows a
.pending"PENDING" badge whenitem.redeeming === true; clicking opens a reward-confirm dialog that callsPOST trigger-reward. A Deny button (added by this feature) callsPOST deny-reward-request.
The notification view refreshes reactively via SSE events: child_chore_confirmation (operations: CONFIRMED, APPROVED, REJECTED, CANCELLED) and child_reward_request (operations: CREATED, CANCELLED, GRANTED).
Spec covered: feat-parent-chore-notifications.md
Implementation
Tests live in e2e/mode_parent/notifications/. Each spec creates its own isolated child and entities via API so files can run in parallel within the bucket. All created resources are deleted in afterAll.
Playwright Projects
| Project | testMatch | Notes |
|---|---|---|
chromium-parent-notifications |
/mode_parent\/notifications\/.+\.spec\.ts/ |
Depends on setup only |
Config: Add project to playwright.config.ts with dependencies: ['setup'] and storageState: STORAGE_STATE. Add /mode_parent\/notifications\// to the testIgnore list of any existing catch-all project so notification specs do not run in two buckets simultaneously.
Seed Strategy
Each spec file uses beforeAll to create isolated children, tasks, and rewards via the authenticated request fixture, and deletes them all in afterAll. The standard pattern is: pre-delete by name, create via PUT, re-fetch to get the ID.
Push subscription tests: Service Worker registration and PushManager.subscribe() require a VAPID public key configured in the dev server environment. Tests that intercept the subscription POST use page.context().grantPermissions(['notifications']) before navigation and page.route() to capture the outgoing request. If the VAPID key is absent, test 1.1 must be skipped with test.skip().
Digest action token prerequisite: The email digest scheduler is disabled in DB_ENV=e2e (to avoid sending real email). To exercise GET /api/digest-action/<token> in E2E tests, the backend must expose a test-only helper endpoint active only when DB_ENV=e2e:
POST /api/admin/test/digest-token
Auth: required (uses the E2E parent session)
Body: { child_id, entity_id, entity_type, action, expires_in_hours? }
Response 200: { token: string }
This endpoint creates a valid DigestActionToken (with correct HMAC-SHA256 signature) in TinyDB and returns the token string. All digest-action tests call this helper in beforeAll to obtain tokens for their scenarios.
Test Scenarios
1. Push Subscription Registration
File: e2e/mode_parent/notifications/push-subscription.spec.ts
Seed: No child/task setup required. Tests use page.context().grantPermissions(['notifications']) as needed. No afterAll cleanup.
1.1. Subscription POST is sent on parent mount when permission is pre-granted
- Call
page.context().grantPermissions(['notifications']) - Intercept
POST /api/push-subscriptionwithpage.route()to capture request body - Navigate to
/parent/tasks/chores(triggers ParentLayout mount → SW registration → subscribe → POST) - expect: Intercepted request body contains a non-empty
endpointstring - expect: Intercepted request body contains non-empty
keys.p256dhandkeys.authstrings - expect: Intercepted request body contains a non-empty
timezonestring
1.2. Subscription POST body includes the browser's IANA timezone string
- Grant notifications permission, intercept
POST /api/push-subscription - Navigate to
/parent/tasks/chores - Obtain browser timezone via
page.evaluate(() => Intl.DateTimeFormat().resolvedOptions().timeZone) - expect: The
timezonefield in the intercepted POST body equals the value frompage.evaluate()
1.3. No subscription POST is sent when notification permission is not granted
- Do NOT grant notification permission (leave default)
- Track calls to
POST /api/push-subscriptionviapage.route() - Navigate to
/parent/tasks/chores, wait for page to settle - expect: No POST to
/api/push-subscriptionwas captured
1.4. Backend rejects unauthenticated subscription POST (401)
- Create a fresh
APIRequestContextwithout storageState (no auth cookies) viaplaywright.request.newContext() - POST to
/api/push-subscriptionwith a minimal payload - expect: Response status is 401
1.5. Backend rejects unauthenticated subscription DELETE (401)
- Using the same unauthenticated context as 1.4, send
DELETE /api/push-subscription - expect: Response status is 401
2. Chore Notification — Appearance and In-App Approval
File: e2e/mode_parent/notifications/chore-notification-approve.spec.ts
Seed: Creates child ChoreNotifApproveChild and chore ChoreNotifApproveChore (5 points, type chore) via API. Assigns chore to child. Sets a daily interval schedule (mode: 'interval', interval_days: 1, interval_has_deadline: false) so the chore persists post-approval. In beforeAll, calls POST /child/<childId>/confirm-chore with { task_id: choreId }. Deletes child and task in afterAll.
2.1. Confirmed chore appears in the notification view
- Navigate to
/parent/notifications - expect: A list item is visible containing the text
ChoreNotifApproveChore - expect: The item contains the word "completed" (not "requested")
2.2. Notification item displays the child's name
- Navigate to
/parent/notifications - expect: The notification item for
ChoreNotifApproveChorealso showsChoreNotifApproveChild
2.3. Clicking notification navigates to ParentView with correct query params
- Navigate to
/parent/notifications - Click the
ChoreNotifApproveChorenotification item await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=chore'), { timeout: 5000 })- expect: URL contains
scrollTo=<choreId>andentityType=chore - expect: The "Chores" section heading is visible
2.4. Pending chore card shows PENDING stamp in ParentView
- Navigate to
/parent/<childId> - Locate the chore card for
ChoreNotifApproveChorein the Chores section - expect: Card contains an element with text "PENDING"
2.5. Clicking a pending chore card opens ChoreApproveDialog
- Navigate to
/parent/<childId> - Click the
ChoreNotifApproveChorecard - expect: A dialog element is visible containing both an "Approve" button and a "Reject" button
2.6. Approving via ChoreApproveDialog removes the PENDING stamp
- Click the "Approve" button in the dialog
- expect: The dialog closes
- expect: The "PENDING" stamp is no longer present on the
ChoreNotifApproveChorecard
2.7. Approving a chore clears it from the notification view
- After approving (2.6), navigate to
/parent/notifications - expect: No list item containing
ChoreNotifApproveChoreis present
2.8. Approving a chore awards points to the child
- Note child's point balance before approval
- Approve the chore via the dialog
- expect: Child's point balance increases by the chore's point value (5)
3. Chore Notification — In-App Rejection
File: e2e/mode_parent/notifications/chore-notification-deny.spec.ts
Seed: Creates child ChoreNotifDenyChild and chore ChoreNotifDenyChore (5 points) via API. Assigns chore. Sets daily interval schedule. In beforeAll, calls POST /child/<childId>/confirm-chore. Deletes all in afterAll.
3.1. Confirmed chore appears in the notification view
- Navigate to
/parent/notifications - expect: A list item is visible containing
ChoreNotifDenyChore
3.2. ChoreApproveDialog shows both Approve and Reject buttons
- Navigate to
/parent/<childId>, click theChoreNotifDenyChorecard - expect: Dialog with "Approve" and "Reject" buttons is visible
3.3. Rejecting via ChoreApproveDialog removes the PENDING stamp
- Click the "Reject" button
- expect: Dialog closes
- expect: "PENDING" stamp is no longer present on the
ChoreNotifDenyChorecard
3.4. Rejecting a chore clears it from the notification view
- After rejecting (3.3), navigate to
/parent/notifications - expect: No list item containing
ChoreNotifDenyChoreis present
3.5. Rejecting a chore does not award points
- Note child's point balance before rejection
- Reject the pending chore via ChoreApproveDialog
- expect: Child's point balance is unchanged
4. Reward Notification — Appearance and In-App Grant
File: e2e/mode_parent/notifications/reward-notification-approve.spec.ts
Seed: Creates child RewardNotifApproveChild with points = 30 via API. Creates reward RewardNotifApproveReward with cost = 10. Assigns reward to child. In beforeAll, calls POST /child/<childId>/request-reward with { reward_id: rewardId }. Deletes child and reward in afterAll.
4.1. Requested reward appears in the notification view
- Navigate to
/parent/notifications - expect: A list item is visible containing
RewardNotifApproveReward - expect: The item contains the word "requested" (not "completed")
4.2. Notification item displays the child's name
- Navigate to
/parent/notifications - expect: The notification item for
RewardNotifApproveRewardalso showsRewardNotifApproveChild
4.3. Clicking reward notification navigates to ParentView with correct query params
- Navigate to
/parent/notifications - Click the
RewardNotifApproveRewardnotification item await page.waitForURL(new RegExp('/parent/' + childId + '\\?scrollTo=.*&entityType=reward'), { timeout: 5000 })- expect: URL contains
scrollTo=<rewardId>andentityType=reward
4.4. Pending reward card shows PENDING badge in ParentView
- Navigate to
/parent/<childId> - Locate the reward card for
RewardNotifApproveRewardin the Rewards section - expect: Card contains an element with text "PENDING"
4.5. Clicking a pending reward card opens the grant confirmation dialog
- Navigate to
/parent/<childId>, click theRewardNotifApproveRewardcard - expect: A reward confirmation dialog appears
4.6. Confirming the grant removes the PENDING badge and updates child points
- In the grant confirmation dialog, click the confirm button
- expect: Dialog closes
- expect: The "PENDING" badge is no longer visible on the reward card
- expect: Child's point balance decreases by the reward cost (30 → 20)
4.7. Granting a reward clears it from the notification view
- After confirming the grant (4.6), navigate to
/parent/notifications - expect: No list item containing
RewardNotifApproveRewardis present
5. Reward Notification — In-App Denial
File: e2e/mode_parent/notifications/reward-notification-deny.spec.ts
Seed: Creates child RewardNotifDenyChild with points = 30. Creates reward RewardNotifDenyReward with cost = 10. Assigns reward to child. Uses beforeEach to call POST /child/<childId>/request-reward (creates a fresh pending request before each test) and afterEach to call POST /child/<childId>/cancel-request-reward to clean up any unconsumed requests. Deletes child and reward in afterAll.
5.1. Requested reward appears in the notification view
- Navigate to
/parent/notifications - expect: A list item is visible containing
RewardNotifDenyReward
5.2. Pending reward card shows PENDING badge in ParentView
- Navigate to
/parent/<childId> - expect: The
RewardNotifDenyRewardcard shows a "PENDING" badge
5.3. Denying a reward request removes the PENDING badge
- Trigger the deny action for the pending reward (target the Deny button by its label or role — exact placement determined by implementation)
- expect: The "PENDING" badge disappears from the reward card
- expect: Child's point balance is unchanged
5.4. Denying a reward request clears it from the notification view
- After denying (5.3), navigate to
/parent/notifications - expect: No list item containing
RewardNotifDenyRewardis present
5.5. POST deny-reward-request returns success when a pending request exists (API layer)
- Via the authenticated
requestfixture, callPOST /child/<childId>/deny-reward-requestwith{ reward_id: rewardId } - expect: Response status is 200
5.6. POST deny-reward-request returns 404 when no pending request exists (API layer)
- Cancel any existing pending request first via
POST /child/<childId>/cancel-request-reward - Call
POST /child/<childId>/deny-reward-requestwith{ reward_id: rewardId } - expect: Response status is 404
6. ParentView Deep-Link Navigation
File: e2e/mode_parent/notifications/deep-link-navigation.spec.ts
Seed: Creates child DeepLinkChild with points = 100. Creates 10 chores DeepLinkChore1 … DeepLinkChore10 (each 5 points, type chore). Creates reward DeepLinkReward with cost = 20. Assigns all 10 chores and the reward to the child. Confirms DeepLinkChore10 via POST /child/<childId>/confirm-chore. Requests the reward via POST /child/<childId>/request-reward. Uses narrow viewport { width: 480, height: 800 } so cards near the bottom require actual scrolling. Deletes child, all tasks, and reward in afterAll.
6.1. Deep link to chore — correct card is scrolled into viewport
await page.setViewportSize({ width: 480, height: 800 })- Navigate to
/parent/<childId>?scrollTo=<choreId>&entityType=chore - Wait for chore list to load and allow 800 ms for the scroll delay to complete
- expect: The
DeepLinkChore10card is within the visible viewport
6.2. Deep link to reward — correct card is scrolled into viewport
await page.setViewportSize({ width: 480, height: 800 })- Navigate to
/parent/<childId>?scrollTo=<rewardId>&entityType=reward - Wait for reward list to load and scroll delay
- expect: The
DeepLinkRewardcard is within the visible viewport
6.3. Deep link with unknown entityType — page loads without JS error
- Listen for
page.on('pageerror', ...)before navigating - Navigate to
/parent/<childId>?scrollTo=<choreId>&entityType=unknown - expect: No pageerror event fires
- expect: Page loads and the "Chores" section heading is visible
6.4. Deep link with missing scrollTo — page loads normally
- Navigate to
/parent/<childId>?entityType=chore(noscrollToquery param) - expect: Page loads without error and "Chores" section is visible
6.5. Deep link with no query params — page loads normally
- Navigate to
/parent/<childId>(bare URL, no query params) - expect: Page loads and both "Chores" and "Rewards" section headings are visible
6.6. Clicking a chore notification produces the correct deep link
- Navigate to
/parent/notifications - Click the
DeepLinkChore10notification item (confirmed chore) - expect: URL matches
/parent/<childId>?scrollTo=<choreId>&entityType=chore - Wait 800 ms for scroll delay
- expect:
DeepLinkChore10card is in viewport
6.7. Clicking a reward notification produces the correct deep link
- Navigate to
/parent/notifications - Click the
DeepLinkRewardnotification item - expect: URL matches
/parent/<childId>?scrollTo=<rewardId>&entityType=reward - Wait 800 ms
- expect:
DeepLinkRewardcard is in viewport
7. Digest Action Token — Happy Paths
Each combination of entity_type × action gets its own spec file to keep seeds isolated and specs independently runnable.
Files:
e2e/mode_parent/notifications/digest-action-approve-chore.spec.tse2e/mode_parent/notifications/digest-action-deny-chore.spec.tse2e/mode_parent/notifications/digest-action-approve-reward.spec.tse2e/mode_parent/notifications/digest-action-deny-reward.spec.ts
Seed (shared pattern for all four specs): Creates an isolated child, task or reward, assigns it, creates a pending confirmation (via confirm-chore or request-reward), then calls the authenticated POST /api/admin/test/digest-token helper to obtain a valid signed token for the required action. All GET /digest-action/<token> calls use a fresh unauthenticated APIRequestContext (no cookies) created via playwright.request.newContext({ ignoreHTTPSErrors: true }). The verifying GET /api/pending-confirmations and GET /api/child/<id> calls use the authenticated request fixture. Deletes child and task/reward in afterAll.
7.1. Approve-chore token — 302 redirect to correct ParentView URL
- Call
GET /api/digest-action/<approve_chore_token>(unauthenticated, redirect following disabled) - expect: Response status is 302
- expect:
response.headers()['location']contains/parent/<childId>,scrollTo=<choreId>, andentityType=chore
7.2. Approve-chore token — approve action is performed, points awarded
- After calling token (7.1), wait 200 ms for the action to complete
- Via authenticated
request:GET /api/pending-confirmations - expect: No pending confirmation record exists for this chore and child
- Via authenticated
request:GET /api/child/<childId> - expect: Points increased by the chore's point value
7.3. Deny-chore token — 302 redirect to correct ParentView URL
- Call
GET /api/digest-action/<deny_chore_token>(unauthenticated, redirect following disabled) - expect: Response status is 302
- expect: Location header contains the correct ParentView deep link (
entityType=chore)
7.4. Deny-chore token — rejection action is performed, no points awarded
- After calling token (7.3), verify:
- expect: Pending confirmation for this chore is removed from
GET /api/pending-confirmations - expect: Child's point balance is unchanged
7.5. Approve-reward token — 302 redirect to correct ParentView URL
- Call
GET /api/digest-action/<approve_reward_token>(unauthenticated, redirect following disabled) - expect: Response status is 302
- expect: Location header contains
/parent/<childId>,scrollTo=<rewardId>, andentityType=reward
7.6. Approve-reward token — reward is triggered and child points are deducted
- After calling token (7.5):
- expect: Pending confirmation for this reward is gone from
GET /api/pending-confirmations - expect: Child's point balance decreased by the reward cost
7.7. Deny-reward token — 302 redirect to correct ParentView URL
- Call
GET /api/digest-action/<deny_reward_token>(unauthenticated, redirect following disabled) - expect: Response status is 302
- expect: Location header contains the correct ParentView deep link for this child and reward
7.8. Deny-reward token — pending reward request is removed, points unchanged
- After calling token (7.7):
- expect: Pending confirmation for this reward is gone
- expect: Child's point balance is unchanged
8. Digest Action Token — Error Paths
File: e2e/mode_parent/notifications/digest-action-errors.spec.ts
Seed: Creates child DigestErrorChild and chore DigestErrorChore via API. Assigns chore. Calls POST /child/<childId>/confirm-chore to create a pending confirmation. Uses POST /api/admin/test/digest-token to generate tokens with specific expiry configurations. All GET /digest-action/<token> calls use a fresh unauthenticated context. Deletes child and task in afterAll.
8.1. Expired token returns 400
- Call
POST /api/admin/test/digest-tokenwithexpires_in_hours: -1(already expired at creation) - Call
GET /api/digest-action/<expired_token>(unauthenticated, redirect following disabled) - expect: Response status is 400
- expect: No
Locationheader is present (no redirect occurred)
8.2. Already-used token returns 400 on second use
- Create a valid token via test helper
- First call:
GET /api/digest-action/<token>— expect: status 302 (token consumed, action executed) - Second call:
GET /api/digest-action/<token>with the same token string - expect: Response status is 400
8.3. Tampered token returns 400
- Create a valid token via test helper; store the raw token string
- Modify one character of the token (e.g. replace the final character with a different letter)
- Call
GET /api/digest-action/<tampered_token>(unauthenticated) - expect: Response status is 400
8.4. Completely fake token returns 400
- Call
GET /api/digest-action/this-token-does-not-exist - expect: Response status is 400
8.5. Error response is a plain HTML page with no redirect
- For any 400 response from 8.1–8.4:
- expect:
content-typeheader containstext/html - expect: The response body does not include a
Locationheader
9. Service Worker Push Delivery — Manual Tests and Mock Strategy
Playwright limitation: The following scenarios cannot be automated with standard Playwright:
- Playwright has no API to dispatch a
pushevent to a registered Service Worker.- OS-level notification tray interactions ("Approve"/"Deny" action buttons) are outside the browser's JavaScript sandbox.
- Delivering a real push message requires a live Web Push server sending to the browser's push endpoint, which is impractical in a local E2E environment.
These scenarios must be verified manually during feature QA, or via the mock strategy below.
Recommended Mock Strategy — Service Worker Test Hook
Add a test-only message handler to public/sw.js, guarded by self.location.hostname === 'localhost':
// sw.js — localhost test hook only
self.addEventListener('message', async (event) => {
if (event.data?.type !== '__TEST_NOTIFICATION_CLICK__') return
const { action, childId, entityId, entityType } = event.data
if (action === 'approve') {
const url =
entityType === 'chore'
? `/api/child/${childId}/approve-chore`
: `/api/child/${childId}/trigger-reward`
const body =
entityType === 'chore'
? JSON.stringify({ task_id: entityId })
: JSON.stringify({ reward_id: entityId })
await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body,
})
} else if (action === 'deny') {
const url =
entityType === 'chore'
? `/api/child/${childId}/reject-chore`
: `/api/child/${childId}/deny-reward-request`
const body =
entityType === 'chore'
? JSON.stringify({ task_id: entityId })
: JSON.stringify({ reward_id: entityId })
await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body,
})
} else {
// body tap — focus or open the app at the deep link
const deepLink = `/parent/${childId}?scrollTo=${entityId}&entityType=${entityType}`
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
if (clients[0]) {
await clients[0].focus()
await clients[0].navigate(deepLink)
} else {
await self.clients.openWindow(deepLink)
}
}
})
Invoke from a Playwright spec after the SW is registered and controlling the page:
await page.evaluate(
({ childId, entityId, entityType, action }) => {
navigator.serviceWorker.controller?.postMessage({
type: '__TEST_NOTIFICATION_CLICK__',
action,
childId,
entityId,
entityType,
})
},
{ childId, entityId, entityType, action: 'approve' },
)
This allows a spec to simulate notification action clicks without OS interaction, enabling API verification and navigation assertions on top of the mock.
Manual QA Checklist (no spec file — document only)
- Parent tab active: SSE in-app toast appears; no OS push notification is sent
- Parent tab in background or closed: OS push notification appears showing the child name and chore/reward name
- Notification does NOT appear for a reward request when the child cannot afford the reward
- Notification shows "Approve" and "Deny" action buttons on Chrome desktop; no action buttons on iOS Safari (fallback: body tap only)
- Clicking "Approve" on a chore notification calls
POST approve-chore, dismisses the notification tray entry, and does NOT open or focus the app - Clicking "Deny" on a chore notification calls
POST reject-chore, dismisses, no app focus - Clicking "Approve" on a reward notification calls
POST trigger-reward, dismisses, no app focus - Clicking "Deny" on a reward notification calls
POST deny-reward-request, dismisses, no app focus - Tapping the chore notification body opens (or focuses) the app at
/parent/<childId>?scrollTo=<choreId>&entityType=chorewith the chore card scrolled into view - Tapping the reward notification body opens/focuses the app at
/parent/<childId>?scrollTo=<rewardId>&entityType=rewardwith the reward card scrolled into view - If the app is already open in another tab, tapping the notification body focuses the existing tab and navigates it — a second tab is NOT opened
- iOS Safari fall-back: notification body tap still navigates correctly to the deep link; no action buttons are rendered
- After the app tab is fully closed and a push notification arrives and is tapped, the app correctly opens at the deep-link URL
Test Summary
| # | Group | Spec File(s) | Test Cases |
|---|---|---|---|
| 1 | Push subscription registration | push-subscription.spec.ts |
5 |
| 2 | Chore notification — approve flow | chore-notification-approve.spec.ts |
8 |
| 3 | Chore notification — reject flow | chore-notification-deny.spec.ts |
5 |
| 4 | Reward notification — grant flow | reward-notification-approve.spec.ts |
7 |
| 5 | Reward notification — deny flow | reward-notification-deny.spec.ts |
6 |
| 6 | ParentView deep-link navigation | deep-link-navigation.spec.ts |
7 |
| 7 | Digest token — happy paths | 4 spec files (one per entity×action) |
8 |
| 8 | Digest token — error paths | digest-action-errors.spec.ts |
5 |
| 9 | SW push — manual/mock | (no spec file) | checklist |
Implementation Notes
- A backend test-only endpoint
POST /api/admin/test/digest-token(guarded byDB_ENV=e2e) is a prerequisite for scenario groups 7 and 8 to work. - The exact UI location of the reward-denial button (group 5) is TBD by the feature implementation — update selectors once the button exists.
- The SW mock strategy in group 9 requires a
__TEST_NOTIFICATION_CLICK__message handler added tosw.js(localhost-only, not present in production builds). - The
playwright.config.tscatch-all project must gete2e/mode_parent/notifications/added to itstestIgnorelist to prevent double-running.