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
14 changes: 2 additions & 12 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
name: Claude Code Review

on:
pull_request:
types: [opened, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
issue_comment:
types: [created]
# Temporarily disabled: no triggers.
on: []

jobs:
claude-review:
Expand Down Expand Up @@ -44,4 +35,3 @@ jobs:
--allowed-tools "Read,Grep,Glob,Bash,Task,mcp__github_inline_comment__create_inline_comment"
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# for available options

6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ Chrome Extension (Manifest V3) - Snooze tabs and automatically restore them at a
| `npm test` | Run all tests |
| `npm run typecheck` | Type check |

## Code Style

- TypeScript strict mode, no `any` types
- Use named exports, not default exports
- CSS: use Tailwind utility classes, no custom CSS files

## Testing

- External APIs must be mocked
Expand Down
2 changes: 1 addition & 1 deletion dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Architecture

> Implementation details (structure, algorithms, file organization)
> Implementation details (structure, algorithms, data flow, invariants)

Chrome Extension (Manifest V3) - Snooze tabs and restore at scheduled time

Expand Down
7 changes: 6 additions & 1 deletion dev-docs/SPEC.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Functional Specifications

> User requirements (behavior, timing, constraints)
> Feature requirements for product/QA (user behavior, timing, constraints)

## Terminology

Expand Down Expand Up @@ -40,10 +40,12 @@ All times use user's timezone (settings → system fallback).
## Scope & Shortcuts

**Scope:**

- **Selected Tabs**: Highlighted tabs, no `groupId` → restore in last-focused window
- **Current Window**: All tabs, shared `groupId` → restore in new window

**Keyboard:**

- Single key (e.g., `T`) → Snooze selected tabs
- `Shift` + key → Snooze entire window
- `Shift + P` or window scope → DatePicker preserves scope
Expand All @@ -61,18 +63,21 @@ All times use user's timezone (settings → system fallback).
## UI Themes

Defined in `constants.ts`:

- **Default**: Blue/Indigo monochrome
- **Vivid**: Semantic colors (Tomorrow=Blue, Weekend=Green)
- **Heatmap**: Urgency colors (Later Today=Red, Tomorrow=Orange)

## Data Integrity

**Backup:**

- 3 rotating backups: `snoozedTabs_backup_<ts>`
- Debounced 2s
- Validates before backup

**Recovery:**

- On startup: Validate `snoooze_v2`
- If invalid → `recoverFromBackup` (valid → sanitized with most items → empty reset)
- `ensureValidStorage` sanitizes invalid entries
Expand Down
172 changes: 165 additions & 7 deletions src/background/serviceWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,12 @@ describe('serviceWorker onInstalled event', () => {
expect(popCheck).toHaveBeenCalledOnce();
});

it('checks for pending recovery notification', async () => {
const sessionGetMock = vi.fn().mockResolvedValue({
pendingRecoveryNotification: 5,
});
// Helper function to reduce test duplication
async function setupRecoveryNotificationTest(sessionData: {
pendingRecoveryNotification: number;
lastRecoveryNotifiedAt?: number;
}) {
const sessionGetMock = vi.fn().mockResolvedValue(sessionData);
const sessionSetMock = vi.fn().mockResolvedValue(undefined);
const sessionRemoveMock = vi.fn().mockResolvedValue(undefined);
const notificationsCreateMock = vi.fn().mockResolvedValue(undefined);
Expand All @@ -243,12 +245,16 @@ describe('serviceWorker onInstalled event', () => {
(globalThis.chrome.notifications.create as ReturnType<typeof vi.fn>) = notificationsCreateMock;

await importServiceWorker();

expect(installedHandler).not.toBeNull();

// Trigger onInstalled event
await installedHandler!();

return { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock };
}

it('checks for pending recovery notification', async () => {
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({ pendingRecoveryNotification: 5 });

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);

Expand All @@ -267,6 +273,158 @@ describe('serviceWorker onInstalled event', () => {
// Should clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});

it('suppresses notification when within 5-minute cooldown', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Test Coverage: Missing Edge Cases

Good coverage of the main cooldown scenarios, but consider adding tests for these edge cases:

  1. Exactly at cooldown boundary (5 minutes exactly) - should this show or suppress?
  2. Invalid timestamp values (negative, far future, NaN)
  3. Multiple rapid restarts within cooldown period

These edge cases would make the test suite more robust.

Copy link
Contributor

Choose a reason for hiding this comment

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

Missing error handling test: The implementation has a try-catch block (lines 89-124 in serviceWorker.ts) that catches errors and logs them with console.warn. However, there are no tests verifying error scenarios:

  1. Notification creation fails: What if chrome.notifications.create throws an error or rejects?
  2. Storage operations fail: What if storage.setSession or storage.removeSession fail?
  3. Partial failures: If notification succeeds but setSession fails, does the pending flag still get cleared?

The catch block prevents crashes, but tests should verify:

  • Error is caught and logged (check console.warn was called)
  • Service worker continues functioning after errors
  • Pending flag cleanup behavior on errors

Copy link
Contributor

Choose a reason for hiding this comment

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

Test naming clarity: Consider renaming to 'shows no notification when within 5-minute cooldown' for consistency with the third test case which says "shows notification". This maintains parallel structure:

  • Test 1 (existing): "checks for pending recovery notification"
  • Test 2 (new): "shows no notification when within 5-minute cooldown"
  • Test 3 (new): "shows notification when cooldown has expired"
  • Test 4 (new): "shows notification when lastRecoveryNotifiedAt is undefined"

Starting with "suppresses" vs "shows no" is subtle, but "shows no" better mirrors the affirmative "shows notification" pattern.

Copy link
Contributor

Choose a reason for hiding this comment

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

Test organization observation: The three new cooldown tests are well-structured and test the main scenarios. However, they're only added to the onInstalled describe block.

The onStartup event handler (line 481+) also calls checkPendingRecoveryNotification(), but has only one basic test that doesn't verify cooldown behavior. The cooldown logic applies to both onInstalled and onStartup events equally.

Consider either:

  1. Adding a note that cooldown is already thoroughly tested in onInstalled tests (DRY principle)
  2. Adding at least one cooldown test in the onStartup block for completeness
  3. Extracting cooldown tests into a shared describe block that covers the function regardless of trigger

This would ensure both entry points have documented test coverage for the cooldown feature.

const now = Date.now();
Copy link
Contributor

Choose a reason for hiding this comment

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

Performance: Redundant Date.now() calls

Each test calls Date.now() to calculate timestamps, which is unnecessary overhead since the tests use fake timers (set in beforeEach line 137).

Recommendation: Use a fixed timestamp constant:

const FIXED_NOW = 1234567890000;
const recentNotification = FIXED_NOW - (3 * 60 * 1000);

Or leverage the fake timers by setting a base time:

beforeEach(() => {
  vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});

Impact: Minor performance gain, but improves test determinism and clarity.

const recentNotification = now - (3 * 60 * 1000); // 3 minutes ago (within 5-min cooldown)

const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 5,
lastRecoveryNotifiedAt: recentNotification,
});

// Should check for pending recovery notification
Copy link
Contributor

Choose a reason for hiding this comment

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

Test Assertion: Unnecessary Check

This assertion is redundant since the test in lines 232-269 already validates that sessionGetMock is called with these exact parameters. The duplicate assertion doesn't add value to this specific test case which focuses on cooldown suppression behavior.

Consider removing this line to keep the test focused on its unique behavior (suppression), or add a comment explaining why this verification is important for this specific test case.

expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);

// Should NOT create notification (within cooldown)
expect(notificationsCreateMock).not.toHaveBeenCalled();

// Should NOT update timestamp (notification suppressed)
Copy link
Contributor

Choose a reason for hiding this comment

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

Documentation accuracy: The comment says "Should NOT update timestamp (notification suppressed)" but this deserves additional context. Consider:

// Should NOT update timestamp (notification suppressed, so lastRecoveryNotifiedAt unchanged)

This clarifies that we're specifically NOT updating lastRecoveryNotifiedAt because the notification was suppressed, which is important for understanding the cooldown mechanism.

expect(sessionSetMock).not.toHaveBeenCalled();

// Should still clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
Copy link
Contributor

Choose a reason for hiding this comment

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

Security Validation - Proper Cleanup: Excellent test coverage here. This assertion confirms that pendingRecoveryNotification is cleared even when the notification is suppressed by cooldown. This prevents:

  1. Replay attacks: The pending flag can't be reused to spam notifications
  2. State corruption: Ensures clean state after cooldown suppression
  3. Resource leaks: Session storage is properly cleaned up

This is a good security practice for state management.

});
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing boundary condition test: This test uses 3 minutes (well within cooldown), but there's no test for the exact boundary at 5 minutes. According to the implementation logic (now - lastNotified) > NOTIFICATION_COOLDOWN, a notification at exactly 5 minutes (300,000ms) should be suppressed, but at 5 minutes + 1ms it should show.

Consider adding a test case:

const exactBoundary = now - (5 * 60 * 1000); // Exactly 5 minutes - should suppress


it('shows notification when cooldown has expired', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Test naming precision: The test name could be more precise about what "cooldown has expired" means. Consider:

it('shows notification when 5-minute cooldown has expired', async () => {

This explicitly mentions the 5-minute threshold, matching the first test which also mentions "5-minute cooldown".

const now = Date.now();
const oldNotification = now - (6 * 60 * 1000); // 6 minutes ago (exceeds 5-min cooldown)

const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 3,
lastRecoveryNotifiedAt: oldNotification,
});

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
Copy link
Contributor

Choose a reason for hiding this comment

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

Test Assertion: Redundant Check

Similar to line 295, this assertion duplicates what's already tested in the basic test case (lines 232-269). Consider removing to reduce noise and keep the test focused on the cooldown expiration behavior.


// Should create notification (cooldown expired)
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
type: 'basic',
iconUrl: 'assets/icon128.png',
title: 'Snooooze Data Recovered',
message: 'Recovered 3 snoozed tabs from backup.',
priority: 1
});
Comment on lines +314 to +320
Copy link
Contributor

Choose a reason for hiding this comment

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

Performance: Verbose object assertion

The full object literal comparison forces the test framework to deep-compare every property. While necessary for correctness, this is repeated across 3 tests (lines 256-262, 336-342, 376-382) with nearly identical content.

Recommendation: Extract expected notification object to a helper function:

const expectRecoveryNotification = (count: number) => ({
  type: 'basic',
  iconUrl: 'assets/icon128.png',
  title: 'Snooooze Data Recovered',
  message: `Recovered ${count} snoozed tabs from backup.`,
  priority: 1
});

Then use:

expect(notificationsCreateMock).toHaveBeenCalledWith(
  'recovery-notification',
  expectRecoveryNotification(3)
);

Impact: Cleaner test code, slightly faster comparison, easier maintenance.


// Should update timestamp
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
Copy link
Contributor

Choose a reason for hiding this comment

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

Test Robustness: Timestamp Validation Could Be Stricter

The assertion expect.any(Number) is very permissive. Consider validating that the timestamp is reasonable:

expect(sessionSetMock).toHaveBeenCalledWith({ 
  lastRecoveryNotifiedAt: expect.any(Number) 
});

// Validate the timestamp is close to current time
const [[callArg]] = sessionSetMock.mock.calls;
expect(callArg.lastRecoveryNotifiedAt).toBeGreaterThan(oldNotification);
expect(callArg.lastRecoveryNotifiedAt).toBeLessThanOrEqual(Date.now());

This ensures the timestamp is actually being set to a current/valid value rather than any arbitrary number.

Copy link
Contributor

Choose a reason for hiding this comment

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

Performance: expect.any(Number) runtime overhead

Using expect.any(Number) requires runtime type checking. Since fake timers are active (line 137), the exact timestamp can be determined.

Recommendation: Use exact value matching with fake timers:

// In beforeEach:
vi.setSystemTime(new Date('2024-01-01T12:00:00Z'));

// In test:
expect(sessionSetMock).toHaveBeenCalledWith({ 
  lastRecoveryNotifiedAt: new Date('2024-01-01T12:00:00Z').getTime() 
});

Impact: Eliminates type checking overhead, more precise assertions.

Copy link
Contributor

Choose a reason for hiding this comment

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

Weak timestamp assertion: Using expect.any(Number) doesn't validate that the timestamp is actually current. Consider strengthening this assertion:

const timestampCall = sessionSetMock.mock.calls[0][0];
expect(timestampCall.lastRecoveryNotifiedAt).toBeGreaterThanOrEqual(now);
expect(timestampCall.lastRecoveryNotifiedAt).toBeLessThanOrEqual(Date.now());

This ensures the timestamp is:

  • Not using the old lastRecoveryNotifiedAt value
  • Set to a reasonable current time
  • Not a random/invalid number


// Should clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing boundary condition test: Similar to the suppression test, this uses 6 minutes (safely expired), but there's no test for the exact boundary. Consider testing the edge case at exactly 5 minutes + 1ms to verify the > operator behavior:

const justExpired = now - (5 * 60 * 1000 + 1); // 5 minutes + 1ms - should show


it('shows notification when lastRecoveryNotifiedAt is undefined (first time)', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Test description vs PR description mismatch: The PR description says "First-time notification" but the test name says "when lastRecoveryNotifiedAt is undefined (first time)".

While technically accurate, this test name conflates two concepts:

  1. The technical condition: lastRecoveryNotifiedAt is undefined
  2. The semantic meaning: "first time"

Consider whether this is truly the "first time" scenario, or simply the "no previous notification timestamp" scenario. These could be different if lastRecoveryNotifiedAt could be cleared/undefined in other situations.

If this is definitively the first-time scenario, consider renaming to:

it('shows notification on first recovery (no previous timestamp)', async () => {

If lastRecoveryNotifiedAt can be undefined in other scenarios, the current name is actually better.

const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 2,
// lastRecoveryNotifiedAt is undefined (first time)
});

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);

// Should create notification (first time, no previous notification)
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
type: 'basic',
iconUrl: 'assets/icon128.png',
title: 'Snooooze Data Recovered',
message: 'Recovered 2 snoozed tabs from backup.',
priority: 1
});

// Should update timestamp
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });

// Should clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});

it('suppresses notification at exactly 5-minute boundary', async () => {
const now = Date.now();
const NOTIFICATION_COOLDOWN = 5 * 60 * 1000;
const exactBoundary = now - NOTIFICATION_COOLDOWN; // Exactly 5 minutes

const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 4,
lastRecoveryNotifiedAt: exactBoundary,
});

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);
Copy link
Contributor

Choose a reason for hiding this comment

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

Test Assertion: Redundant Check

This is the third test with this identical assertion. Since you're testing different cooldown scenarios, the sessionGetMock call verification adds limited value. Consider removing it from all three cooldown tests to keep them focused on their specific behavior differences.


// Should NOT create notification (boundary case: condition uses > not >=)
expect(notificationsCreateMock).not.toHaveBeenCalled();

// Should NOT update timestamp
expect(sessionSetMock).not.toHaveBeenCalled();

// Should still clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});

it('shows corruption message when zero tabs recovered', async () => {
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 0, // Corruption case
});

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);

// Should create notification with corruption message
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
type: 'basic',
iconUrl: 'assets/icon128.png',
title: 'Snooooze Data Recovered',
message: 'Snoozed tabs data was reset due to corruption.',
priority: 1
});

// Should update timestamp
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });

// Should clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});

it('uses singular "tab" when recovering one tab', async () => {
const { sessionGetMock, sessionSetMock, sessionRemoveMock, notificationsCreateMock } =
await setupRecoveryNotificationTest({
pendingRecoveryNotification: 1, // Singular case
});

// Should check for pending recovery notification
expect(sessionGetMock).toHaveBeenCalledWith(['pendingRecoveryNotification', 'lastRecoveryNotifiedAt']);

// Should create notification with singular "tab" (no 's')
expect(notificationsCreateMock).toHaveBeenCalledWith('recovery-notification', {
type: 'basic',
iconUrl: 'assets/icon128.png',
title: 'Snooooze Data Recovered',
message: 'Recovered 1 snoozed tab from backup.',
priority: 1
});

// Should update timestamp
expect(sessionSetMock).toHaveBeenCalledWith({ lastRecoveryNotifiedAt: expect.any(Number) });
Copy link
Contributor

Choose a reason for hiding this comment

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

Weak timestamp assertion: Same issue as the other tests - expect.any(Number) is too permissive. The timestamp should be validated to ensure it's set to the current time, not just any number.


// Should clear pending flag
expect(sessionRemoveMock).toHaveBeenCalledWith('pendingRecoveryNotification');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing test coverage for edge cases:

  1. tabCount = 0 case: The implementation has special message logic (lines 108-110 in serviceWorker.ts):

    message: tabCount > 0
      ? `Recovered ${tabCount} snoozed tab${tabCount > 1 ? 's' : ''} from backup.`
      : 'Snoozed tabs data was reset due to corruption.',

    There's no test verifying the corruption message when pendingRecoveryNotification: 0.

  2. tabCount = 1 (singular form): All tests use counts ≥ 2. Need to verify "1 snoozed tab" (no 's').

  3. Invalid/negative tabCount: What happens with pendingRecoveryNotification: -1 or pendingRecoveryNotification: null?

  4. lastRecoveryNotifiedAt = 0: Edge case where timestamp is 0 (epoch start). Current implementation would treat this as a very old notification and show, but worth explicit test.

  5. lastRecoveryNotifiedAt is future timestamp: Clock skew or manipulation could cause lastNotified > now. The calculation (now - lastNotified) would be negative, failing the > NOTIFICATION_COOLDOWN check and suppressing notification indefinitely.

});

describe('serviceWorker onStartup event', () => {
Expand Down