diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 57ca50d2a1..41bc5119be 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -225,7 +225,7 @@ jobs: node-version: '20' - name: ๐Ÿ“ฅ Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: path: all-reports diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index f820167d2f..72f58b80c2 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -23,37 +23,37 @@ jobs: test: name: Run JavaScript Tests runs-on: ubuntu-latest - + strategy: matrix: node-version: [18, 20] - + steps: - name: ๐Ÿ“‚ Checkout repository uses: actions/checkout@v5 - + - name: ๐ŸŸข Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' - + - name: ๐Ÿ“ฆ Install dependencies run: | npm ci # Also install optional reporter if needed npm install jest-html-reporter --save-dev || true - + - name: ๐Ÿงช Run unit tests run: npm test -- --coverage --ci --maxWorkers=2 env: CI: true - + - name: ๐Ÿ“Š Generate coverage report if: matrix.node-version == '20' # Only run coverage on one version run: | npm run test:coverage || true - + - name: ๐Ÿ“ค Upload coverage to Codecov if: matrix.node-version == '20' uses: codecov/codecov-action@v5 @@ -62,7 +62,7 @@ jobs: flags: frontend name: frontend-coverage fail_ci_if_error: false - + - name: ๐Ÿ“‹ Upload test results if: always() uses: actions/upload-artifact@v4 @@ -72,7 +72,7 @@ jobs: coverage/ test-report.html retention-days: 7 - + - name: ๐Ÿ’ฌ Comment PR with results if: github.event_name == 'pull_request' && matrix.node-version == '20' uses: actions/github-script@v7 @@ -80,19 +80,19 @@ jobs: script: | const fs = require('fs'); let coverageText = '๐Ÿ“Š **Frontend Test Coverage Report**\n\n'; - + try { // Try to read coverage summary if it exists const coveragePath = './coverage/coverage-summary.json'; if (fs.existsSync(coveragePath)) { const coverage = JSON.parse(fs.readFileSync(coveragePath, 'utf8')); const total = coverage.total; - + coverageText += '| Metric | Coverage | Status |\n'; coverageText += '|--------|----------|--------|\n'; - + const getStatus = (pct) => pct >= 80 ? 'โœ…' : pct >= 70 ? 'โš ๏ธ' : 'โŒ'; - + coverageText += `| Lines | ${total.lines.pct}% | ${getStatus(total.lines.pct)} |\n`; coverageText += `| Statements | ${total.statements.pct}% | ${getStatus(total.statements.pct)} |\n`; coverageText += `| Functions | ${total.functions.pct}% | ${getStatus(total.functions.pct)} |\n`; @@ -103,19 +103,19 @@ jobs: } catch (error) { coverageText += `โš ๏ธ Could not parse coverage report: ${error.message}\n`; } - + // Find and update or create comment const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && comment.body.includes('Frontend Test Coverage Report') ); - + if (botComment) { await github.rest.issues.updateComment({ owner: context.repo.owner, @@ -135,22 +135,22 @@ jobs: lint-js: name: Lint JavaScript runs-on: ubuntu-latest - + steps: - name: ๐Ÿ“‚ Checkout repository uses: actions/checkout@v5 - + - name: ๐ŸŸข Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - + - name: ๐Ÿ“ฆ Install ESLint run: | npm install --save-dev eslint npm install --save-dev eslint-plugin-jest || true - + - name: ๐Ÿ” Run ESLint run: | npx eslint static/js/*.js --ignore-pattern "*.min.js" || true @@ -160,42 +160,42 @@ jobs: name: Integration Tests runs-on: ubuntu-latest needs: test - + steps: - name: ๐Ÿ“‚ Checkout repository uses: actions/checkout@v5 - + - name: ๐ŸŸข Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - + - name: ๐Ÿ’Ž Setup Ruby uses: ruby/setup-ruby@v1 with: ruby-version: '3.3' bundler-cache: true - + - name: ๐Ÿ“ฆ Install dependencies run: | npm ci bundle install - + - name: ๐Ÿ—๏ธ Build Jekyll site run: | bundle exec jekyll build env: JEKYLL_ENV: test - + - name: ๐Ÿงช Run integration tests run: | npm run test:integration || echo "No integration tests yet" continue-on-error: true - + - name: ๐Ÿ“ค Upload built site uses: actions/upload-artifact@v4 with: name: built-site path: _site/ - retention-days: 1 \ No newline at end of file + retention-days: 1 diff --git a/.gitignore b/.gitignore index a0b1c0261f..b8d32d7238 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,3 @@ utils/tidy_conf/data/.tmp/ # Node modules node_modules/ - diff --git a/_config.test.yml b/_config.test.yml index 4b5b18022b..da1920130b 100644 --- a/_config.test.yml +++ b/_config.test.yml @@ -98,4 +98,4 @@ force_polling: false # Ensure JavaScript files are always fresh sass: style: expanded # Easier to debug - sourcemap: never # Don't generate sourcemaps \ No newline at end of file + sourcemap: never # Don't generate sourcemaps diff --git a/_config_dev.yml b/_config_dev.yml index aeaa3e326b..6e3fcdc55f 100644 --- a/_config_dev.yml +++ b/_config_dev.yml @@ -1,2 +1,2 @@ # Development configuration - single language only -languages: ["en"] \ No newline at end of file +languages: ["en"] diff --git a/_includes/action_bar.html b/_includes/action_bar.html index a2aef1b4a0..751a07c108 100644 --- a/_includes/action_bar.html +++ b/_includes/action_bar.html @@ -6,7 +6,7 @@ -
- \ No newline at end of file + diff --git a/_includes/utils.js b/_includes/utils.js index 904660cdc3..9fe5633b64 100644 --- a/_includes/utils.js +++ b/_includes/utils.js @@ -5,7 +5,7 @@ function update_filtering(data) { } else { // Fallback for legacy support console.warn('ConferenceFilter module not loaded, using legacy filtering'); - + // Defensive check for data parameter if (!data || typeof data !== 'object') { console.error('update_filtering called with invalid data:', data); diff --git a/_layouts/default.html b/_layouts/default.html index fd31af8e8e..40752cbc97 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -10,7 +10,7 @@ {% include header.html %} - + {% if page.show_masthead %} {% capture masthead_title %} {% if page.masthead_title %} @@ -19,36 +19,36 @@ {% t page.title %} {% endif %} {% endcapture %} - + {% capture masthead_hook %} {% if page.masthead_hook %} {{ page.masthead_hook }} {% endif %} {% endcapture %} - + {% capture masthead_description %} {% if page.masthead_description %} {{ page.masthead_description }} {% endif %} {% endcapture %} - + {% include masthead.html url=page.url title=masthead_title hook=masthead_hook description=masthead_description %} {% endif %} - +
{{ content }}
- + {% if page.extra_js %} {% for js in page.extra_js %} {% endfor %} {% endif %} - + {% if page.include_footer %} {% else %} {% include sneks.html %} {% endif %} - \ No newline at end of file + diff --git a/_layouts/home.html b/_layouts/home.html index 816783647c..120f3084f4 100644 --- a/_layouts/home.html +++ b/_layouts/home.html @@ -18,4 +18,4 @@ {{ content }} - \ No newline at end of file + diff --git a/_layouts/page.html b/_layouts/page.html index 84f03f7f55..5e7112684a 100644 --- a/_layouts/page.html +++ b/_layouts/page.html @@ -2,4 +2,4 @@ layout: default --- -{{ content }} \ No newline at end of file +{{ content }} diff --git a/_pages/my-conferences.html b/_pages/my-conferences.html index 969dcc2d01..8fa9b3a179 100644 --- a/_pages/my-conferences.html +++ b/_pages/my-conferences.html @@ -139,7 +139,7 @@
Quick Subscribe

My Conference Dashboard

- 0 favorite conferences | + 0 favorite conferences | 0 series subscriptions

@@ -266,4 +266,4 @@
Series Notifications:
- + diff --git a/index.html b/index.html index 54ea92a75f..88e2c76c89 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ {% assign confs = site.data.conferences | sort: "cfp" | reverse %} {% for conf in confs %} {% assign subs = conf.sub | split: "," %} -
{% t titles.visit_archive %}

- + - + - \ No newline at end of file + diff --git a/test-badges.html b/test-badges.html index b70bd3b88e..3aca8446b4 100644 --- a/test-badges.html +++ b/test-badges.html @@ -28,25 +28,25 @@

Testing Badge Click Handlers

- +
DATA PY WEB
- +
Click log will appear here...
- + - \ No newline at end of file + diff --git a/test-countdown.html b/test-countdown.html index bee033a5bf..00ec44490a 100644 --- a/test-countdown.html +++ b/test-countdown.html @@ -10,33 +10,33 @@

Testing Countdown Timer

Test element:

Loading... - +
- + - + - \ No newline at end of file + diff --git a/test-filtering.html b/test-filtering.html index 585f2d7ec6..09bf301e03 100644 --- a/test-filtering.html +++ b/test-filtering.html @@ -35,16 +35,16 @@

Testing Conference Filtering

- +
Status will appear here...
- +

Click to Filter:

DATA PY WEB
- +

Conferences:

DATA Conference 1
@@ -57,25 +57,25 @@

Conferences:

WEB Conference 1
WEB Conference 2
- + - + - \ No newline at end of file + diff --git a/tests/README.md b/tests/README.md index 2411c52b43..c4323abf94 100644 --- a/tests/README.md +++ b/tests/README.md @@ -214,4 +214,4 @@ test.describe('Feature', () => { - **Unit Tests**: 80% line coverage - **E2E Tests**: All critical user paths - **Visual Tests**: Key page states -- **Performance**: Page load < 3s \ No newline at end of file +- **Performance**: Page load < 3s diff --git a/tests/e2e/global-setup.js b/tests/e2e/global-setup.js index be50be19c0..280564d22a 100644 --- a/tests/e2e/global-setup.js +++ b/tests/e2e/global-setup.js @@ -5,20 +5,20 @@ async function globalSetup(config) { console.log('๐Ÿš€ Starting E2E test suite...'); - + // Set environment variables for tests process.env.TEST_ENV = 'e2e'; process.env.BASE_URL = config.use?.baseURL || 'http://localhost:4000'; - + // Store the start time for test duration tracking global.__TEST_START_TIME__ = Date.now(); - + console.log(`๐Ÿ“ Base URL: ${process.env.BASE_URL}`); console.log(`๐Ÿงช Running ${config.projects.length} test projects`); - + return async () => { // This function will be called as global teardown }; } -module.exports = globalSetup; \ No newline at end of file +module.exports = globalSetup; diff --git a/tests/e2e/global-teardown.js b/tests/e2e/global-teardown.js index bb990b5b3a..cf57dc9f6e 100644 --- a/tests/e2e/global-teardown.js +++ b/tests/e2e/global-teardown.js @@ -7,11 +7,11 @@ async function globalTeardown() { const duration = Date.now() - global.__TEST_START_TIME__; const minutes = Math.floor(duration / 60000); const seconds = ((duration % 60000) / 1000).toFixed(0); - + console.log(`\nโœ… E2E tests completed in ${minutes}m ${seconds}s`); - + // Clean up any test data if needed // This is where you could clean up test conferences, etc. } -module.exports = globalTeardown; \ No newline at end of file +module.exports = globalTeardown; diff --git a/tests/e2e/specs/countdown-timers.spec.js b/tests/e2e/specs/countdown-timers.spec.js index 497654202a..1e32a79298 100644 --- a/tests/e2e/specs/countdown-timers.spec.js +++ b/tests/e2e/specs/countdown-timers.spec.js @@ -16,18 +16,18 @@ test.describe('Countdown Timers', () => { await page.goto('/'); await waitForPageReady(page); }); - + test.describe('Timer Display', () => { test('should display countdown timers for conferences', async ({ page }) => { // Wait for countdowns to initialize await waitForCountdowns(page); - + // Get all countdown elements const countdowns = page.locator('.countdown-display'); const count = await countdowns.count(); - + expect(count).toBeGreaterThan(0); - + // Check that at least one has content let hasContent = false; for (let i = 0; i < count; i++) { @@ -37,48 +37,48 @@ test.describe('Countdown Timers', () => { break; } } - + expect(hasContent).toBe(true); }); - + test('should update countdown every second', async ({ page }) => { await waitForCountdowns(page); - + // Find a countdown with content const countdown = page.locator('.countdown-display').first(); const initialText = await countdown.textContent(); - + // Wait 2 seconds await page.waitForTimeout(2000); - + const updatedText = await countdown.textContent(); - + // Text should have changed (unless it's passed or TBA) if (!initialText?.includes('Passed') && !initialText?.includes('TBA')) { expect(updatedText).not.toBe(initialText); } }); - + test('should show correct format for regular countdown', async ({ page }) => { await waitForCountdowns(page); - + // Find regular countdown (not small) const regularCountdown = page.locator('.countdown-display:not(.countdown-small)').first(); const text = await regularCountdown.textContent(); - + // Should match format: "X days Xh Xm Xs" or "Deadline passed" if (text && !text.includes('Passed') && !text.includes('TBA')) { expect(text).toMatch(/\d+ days? \d+h \d+m \d+s/); } }); - + test('should show compact format for small countdown', async ({ page }) => { // Look for small countdown if exists const smallCountdown = page.locator('.countdown-display.countdown-small'); - + if (await smallCountdown.count() > 0) { const text = await smallCountdown.first().textContent(); - + // Should match format: "Xd XX:XX:XX" or "Passed" if (text && !text.includes('Passed') && !text.includes('TBA')) { expect(text).toMatch(/\d+d \d{2}:\d{2}:\d{2}/); @@ -86,91 +86,91 @@ test.describe('Countdown Timers', () => { } }); }); - + test.describe('Deadline States', () => { test('should show "Deadline passed" for past conferences', async ({ page }) => { await waitForCountdowns(page); - + // Look for passed deadlines const passedCountdowns = page.locator('.countdown-display.deadline-passed, .countdown-display:has-text("passed")'); - + if (await passedCountdowns.count() > 0) { const text = await passedCountdowns.first().textContent(); expect(text).toMatch(/passed/i); } }); - + test('should handle TBA deadlines', async ({ page }) => { await waitForCountdowns(page); - + // Look for TBA deadlines - const tbaElements = await page.$$eval('.countdown-display', elements => + const tbaElements = await page.$$eval('.countdown-display', elements => elements.filter(el => el.dataset.deadline === 'TBA').length ); - + if (tbaElements > 0) { const tbaCountdown = page.locator('.countdown-display[data-deadline="TBA"]').first(); const text = await tbaCountdown.textContent(); expect(text).toBe(''); } }); - + test('should add deadline-passed class to past deadlines', async ({ page }) => { await waitForCountdowns(page); - + const passedCountdowns = page.locator('.countdown-display.deadline-passed'); - + if (await passedCountdowns.count() > 0) { // Should have the deadline-passed class await expect(passedCountdowns.first()).toHaveClass(/deadline-passed/); } }); }); - + test.describe('Timezone Handling', () => { test('should respect timezone data attribute', async ({ page }) => { await waitForCountdowns(page); - + // Check if any countdowns have timezone attributes const timezonedCountdown = page.locator('.countdown-display[data-timezone]').first(); - + if (await timezonedCountdown.count() > 0) { const timezone = await timezonedCountdown.getAttribute('data-timezone'); expect(timezone).toBeTruthy(); - + // Timezone should be valid IANA format or UTC offset expect(timezone).toMatch(/^([A-Z][a-z]+\/[A-Z][a-z]+|UTC[+-]\d+)$/); } }); - + test('should default to UTC-12 (AoE) when no timezone specified', async ({ page }) => { // This is handled internally by the countdown script // We can verify by checking console logs in debug mode - + const logs = []; page.on('console', msg => logs.push(msg.text())); - + await page.reload(); await waitForCountdowns(page); - + // The script uses UTC-12 as default // This test verifies the system doesn't error out const countdowns = page.locator('.countdown-display'); expect(await countdowns.count()).toBeGreaterThan(0); }); }); - + test.describe('Performance', () => { test('should handle many countdowns efficiently', async ({ page }) => { // Navigate to a page with many conferences (archive or main page) await page.goto('/'); await waitForPageReady(page); await waitForCountdowns(page); - + // Count all countdowns const countdowns = page.locator('.countdown-display'); const count = await countdowns.count(); - + // Even with many countdowns, page should remain responsive const startTime = Date.now(); await page.evaluate(() => { @@ -180,41 +180,41 @@ test.describe('Countdown Timers', () => { document.body.style.display = ''; }); const endTime = Date.now(); - + // Should complete quickly even with many timers expect(endTime - startTime).toBeLessThan(100); - + console.log(`Handling ${count} countdown timers`); }); - + test('should clean up timer on page navigation', async ({ page }) => { await waitForCountdowns(page); - + // Set up listener for console messages const consoleLogs = []; page.on('console', msg => consoleLogs.push(msg.text())); - + // Navigate away await page.goto('/about'); - + // Should not have timer errors - const timerErrors = consoleLogs.filter(log => + const timerErrors = consoleLogs.filter(log => log.includes('timer') && log.includes('error') ); expect(timerErrors.length).toBe(0); }); }); - + test.describe('Dynamic Updates', () => { test('should handle dynamically added conferences', async ({ page }) => { await waitForCountdowns(page); - + // Add a new conference element dynamically await page.evaluate(() => { const newConf = document.createElement('div'); newConf.className = 'ConfItem'; newConf.innerHTML = ` -
@@ -222,21 +222,21 @@ test.describe('Countdown Timers', () => { `; document.body.appendChild(newConf); }); - + // Wait a bit for the timer to pick it up await page.waitForTimeout(1500); - + // Check that the new countdown has content const dynamicCountdown = page.locator('#dynamic-countdown'); const text = await dynamicCountdown.textContent(); - + expect(text).toBeTruthy(); expect(text).not.toBe(''); }); - + test('should handle countdown removal', async ({ page }) => { await waitForCountdowns(page); - + // Remove a countdown element await page.evaluate(() => { const countdown = document.querySelector('.countdown-display'); @@ -244,16 +244,16 @@ test.describe('Countdown Timers', () => { countdown.remove(); } }); - + // Should not cause errors await page.waitForTimeout(1500); - + // Page should still be functional const remainingCountdowns = page.locator('.countdown-display'); expect(await remainingCountdowns.count()).toBeGreaterThanOrEqual(0); }); }); - + test.describe('Error Handling', () => { test('should handle invalid date formats gracefully', async ({ page }) => { // Add countdown with invalid date @@ -264,83 +264,83 @@ test.describe('Countdown Timers', () => { invalidCountdown.id = 'invalid-countdown'; document.body.appendChild(invalidCountdown); }); - + await page.waitForTimeout(1500); - + // Should show error message const invalidCountdown = page.locator('#invalid-countdown'); const text = await invalidCountdown.textContent(); - + expect(text).toMatch(/Invalid date|Error/); }); - + test('should handle missing Luxon library', async ({ page }) => { // Navigate to page and remove Luxon await page.evaluate(() => { delete window.luxon; }); - + // Reload the countdown script await page.evaluate(() => { const script = document.createElement('script'); script.src = '/static/js/countdown-simple.js'; document.head.appendChild(script); }); - + await page.waitForTimeout(1000); - + // Should not crash the page const pageTitle = await page.title(); expect(pageTitle).toBeTruthy(); }); }); - + test.describe('Responsive Behavior', () => { test('should work on mobile viewports', async ({ page }) => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }); - + await page.goto('/'); await waitForPageReady(page); await waitForCountdowns(page); - + // Countdowns should still work const countdowns = page.locator('.countdown-display'); const count = await countdowns.count(); - + expect(count).toBeGreaterThan(0); - + // Check text is visible const firstCountdown = countdowns.first(); const text = await firstCountdown.textContent(); - + if (text && !text.includes('TBA')) { expect(text).toBeTruthy(); } }); - + test('should handle orientation change', async ({ page, context }) => { // Start in portrait await page.setViewportSize({ width: 375, height: 667 }); await page.goto('/'); await waitForCountdowns(page); - + // Change to landscape await page.setViewportSize({ width: 667, height: 375 }); await page.waitForTimeout(1000); - + // Countdowns should still be updating const countdown = page.locator('.countdown-display').first(); const text1 = await countdown.textContent(); - + await page.waitForTimeout(2000); - + const text2 = await countdown.textContent(); - + // Should still be updating (unless passed/TBA) if (!text1?.includes('Passed') && !text1?.includes('TBA')) { expect(text2).not.toBe(text1); } }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/specs/notification-system.spec.js b/tests/e2e/specs/notification-system.spec.js index 3cd750be1c..e778979ec0 100644 --- a/tests/e2e/specs/notification-system.spec.js +++ b/tests/e2e/specs/notification-system.spec.js @@ -17,55 +17,55 @@ test.describe('Notification System', () => { test.beforeEach(async ({ page, context }) => { // Clear storage await clearLocalStorage(page); - + // Navigate to home page await page.goto('/'); await waitForPageReady(page); }); - + test.describe('Permission Flow', () => { test('should show notification prompt for new users', async ({ page }) => { // Check if notification prompt is visible const prompt = page.locator('#notification-prompt'); await expect(prompt).toBeVisible({ timeout: 5000 }); }); - + test('should request permission when enable button clicked', async ({ page, context }) => { // Grant permission at browser level await grantNotificationPermission(context); - + // Click enable notifications button const enableBtn = page.locator('#enable-notifications'); if (await enableBtn.isVisible()) { await enableBtn.click(); - + // Should show success toast const toast = await waitForToast(page); await expect(toast).toContainText('Notifications Enabled'); } }); - + test('should hide prompt after permission granted', async ({ page, context }) => { await grantNotificationPermission(context); - + const prompt = page.locator('#notification-prompt'); const enableBtn = page.locator('#enable-notifications'); - + if (await enableBtn.isVisible()) { await enableBtn.click(); await expect(prompt).toBeHidden({ timeout: 5000 }); } }); }); - + test.describe('Deadline Notifications', () => { test.beforeEach(async ({ page, context }) => { // Grant notification permission await grantNotificationPermission(context); - + // Set up mock conferences with various deadlines const conferences = [ - createMockConference({ + createMockConference({ id: 'conf-7days', conference: 'PyCon Test 7 Days', cfp: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ' 23:59:59' @@ -81,25 +81,25 @@ test.describe('Notification System', () => { cfp: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ' 23:59:59' }) ]; - + await setupSavedConferences(page, conferences); }); - + test('should check for upcoming deadlines on page load', async ({ page }) => { // Reload page to trigger notification check await page.reload(); await waitForPageReady(page); - + // Check localStorage for notification records const notified = await page.evaluate(() => { const data = localStorage.getItem('pythondeadlines-notified-deadlines'); return data ? JSON.parse(data) : {}; }); - + // Should have notification records expect(Object.keys(notified).length).toBeGreaterThan(0); }); - + test('should show in-app toast for upcoming deadlines', async ({ page }) => { // Trigger notification check await page.evaluate(() => { @@ -107,19 +107,19 @@ test.describe('Notification System', () => { window.NotificationManager.checkUpcomingDeadlines(); } }); - + // Look for toast notifications const toasts = page.locator('.toast'); const count = await toasts.count(); - + // Should show at least one toast expect(count).toBeGreaterThan(0); - + // Check toast content const firstToast = toasts.first(); await expect(firstToast).toContainText(/days? until CFP deadline/); }); - + test('should not show duplicate notifications', async ({ page }) => { // First check await page.evaluate(() => { @@ -127,83 +127,83 @@ test.describe('Notification System', () => { window.NotificationManager.checkUpcomingDeadlines(); } }); - + // Wait and dismiss toasts await page.waitForTimeout(1000); await page.evaluate(() => { document.querySelectorAll('.toast').forEach(t => t.remove()); }); - + // Second check - should not show duplicates await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.checkUpcomingDeadlines(); } }); - + await page.waitForTimeout(500); - + // Should not have new toasts const toasts = page.locator('.toast:visible'); const count = await toasts.count(); expect(count).toBe(0); }); }); - + test.describe('Notification Settings', () => { test('should open settings modal', async ({ page }) => { // Click notification settings button (if exists) const settingsBtn = page.locator('[data-target="#notificationModal"], [data-bs-target="#notificationModal"]').first(); - + if (await settingsBtn.isVisible()) { await settingsBtn.click(); - + // Modal should be visible const modal = page.locator('#notificationModal'); await expect(modal).toBeVisible(); - + // Should have notification day options await expect(modal.locator('.notify-days')).toHaveCount(4); // 14, 7, 3, 1 days } }); - + test('should save notification preferences', async ({ page }) => { const settingsBtn = page.locator('[data-target="#notificationModal"], [data-bs-target="#notificationModal"]').first(); - + if (await settingsBtn.isVisible()) { await settingsBtn.click(); - + const modal = page.locator('#notificationModal'); - + // Uncheck 14-day notifications await modal.locator('input[value="14"]').uncheck(); - + // Check 1-day notifications await modal.locator('input[value="1"]').check(); - + // Save settings await modal.locator('#save-notification-settings').click(); - + // Modal should close await expect(modal).toBeHidden({ timeout: 5000 }); - + // Verify settings were saved const settings = await page.evaluate(() => { const data = localStorage.getItem('pythondeadlines-notification-settings'); return data ? JSON.parse(data) : null; }); - + expect(settings).toBeTruthy(); expect(settings.days).toContain(1); expect(settings.days).not.toContain(14); } }); }); - + test.describe('Action Bar Notifications', () => { test('should trigger notifications for action bar saved conferences', async ({ page, context }) => { await grantNotificationPermission(context); - + // Set up action bar preferences await page.evaluate(() => { const prefs = { @@ -211,7 +211,7 @@ test.describe('Notification System', () => { }; localStorage.setItem('pydeadlines_actionBarPrefs', JSON.stringify(prefs)); }); - + // Add a conference element to the page await page.evaluate(() => { const conf = document.createElement('div'); @@ -221,28 +221,28 @@ test.describe('Notification System', () => { conf.dataset.cfp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + ' 23:59:59'; document.body.appendChild(conf); }); - + // Clear last check to allow notification await page.evaluate(() => { localStorage.removeItem('pydeadlines_lastNotifyCheck'); }); - + // Trigger action bar notification check await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.checkActionBarNotifications(); } }); - + // Check that notification was scheduled const notifyRecord = await page.evaluate(() => { return localStorage.getItem('pydeadlines_notify_conf-test_7'); }); - + expect(notifyRecord).toBeTruthy(); }); }); - + test.describe('Toast Notifications', () => { test('should show different toast styles', async ({ page }) => { // Test info toast @@ -251,100 +251,100 @@ test.describe('Notification System', () => { window.NotificationManager.showInAppNotification('Info', 'This is info', 'info'); } }); - + let toast = await waitForToast(page); await expect(toast.locator('.toast-header')).toHaveClass(/bg-info/); - + // Clear toast await page.evaluate(() => { document.querySelector('.toast')?.remove(); }); - + // Test warning toast await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.showInAppNotification('Warning', 'This is warning', 'warning'); } }); - + toast = await waitForToast(page); await expect(toast.locator('.toast-header')).toHaveClass(/bg-warning/); - + // Clear toast await page.evaluate(() => { document.querySelector('.toast')?.remove(); }); - + // Test success toast await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.showInAppNotification('Success', 'This is success', 'success'); } }); - + toast = await waitForToast(page); await expect(toast.locator('.toast-header')).toHaveClass(/bg-success/); }); - + test('should auto-dismiss toasts', async ({ page }) => { await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.showInAppNotification('Test', 'Auto dismiss test', 'info'); } }); - + const toast = await waitForToast(page); await expect(toast).toBeVisible(); - + // Wait for auto-dismiss (usually 5 seconds) await page.waitForTimeout(6000); await expect(toast).toBeHidden(); }); - + test('should allow manual dismiss', async ({ page }) => { await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.showInAppNotification('Test', 'Manual dismiss test', 'info'); } }); - + const toast = await waitForToast(page); const closeBtn = toast.locator('[data-dismiss="toast"], [data-bs-dismiss="toast"]'); - + await closeBtn.click(); await expect(toast).toBeHidden({ timeout: 1000 }); }); }); - + test.describe('Responsive Behavior', () => { test('should work on mobile viewport', async ({ page, context }) => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }); - + await grantNotificationPermission(context); await page.goto('/'); await waitForPageReady(page); - + // Notification system should still initialize const hasNotificationManager = await page.evaluate(() => { return typeof window.NotificationManager !== 'undefined'; }); - + expect(hasNotificationManager).toBe(true); - + // Test toast on mobile await page.evaluate(() => { if (window.NotificationManager) { window.NotificationManager.showInAppNotification('Mobile Test', 'Works on mobile', 'info'); } }); - + const toast = await waitForToast(page); await expect(toast).toBeVisible(); - + // Toast should be responsive const toastBox = await toast.boundingBox(); expect(toastBox.width).toBeLessThanOrEqual(375); }); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/utils/helpers.js b/tests/e2e/utils/helpers.js index 287fbe39fd..7fc9a54e09 100644 --- a/tests/e2e/utils/helpers.js +++ b/tests/e2e/utils/helpers.js @@ -8,7 +8,7 @@ export async function waitForCountdowns(page) { // Wait for Luxon to be available await page.waitForFunction(() => window.luxon !== undefined); - + // Wait for at least one countdown to have content await page.waitForFunction(() => { const countdowns = document.querySelectorAll('.countdown-display'); @@ -24,7 +24,7 @@ export async function mockDateTime(page, dateString) { // Override Date constructor const RealDate = Date; const mockedDate = new RealDate(mockDate); - + window.Date = class extends RealDate { constructor(...args) { if (args.length === 0) { @@ -32,16 +32,16 @@ export async function mockDateTime(page, dateString) { } return new RealDate(...args); } - + static now() { return mockedDate.getTime(); } }; - + // Preserve other Date methods Object.setPrototypeOf(window.Date, RealDate); window.Date.prototype = RealDate.prototype; - + // Also mock performance.now if needed const originalPerformanceNow = performance.now; const performanceNowOffset = originalPerformanceNow(); @@ -74,7 +74,7 @@ export async function setupSavedConferences(page, conferences) { // Set up favorites const favoriteIds = confs.map(c => c.id); localStorage.setItem('pythondeadlines-favorites', JSON.stringify(favoriteIds)); - + // Set up saved conference data const savedData = {}; confs.forEach(conf => { @@ -85,7 +85,7 @@ export async function setupSavedConferences(page, conferences) { }; }); localStorage.setItem('pythondeadlines-saved-conferences', JSON.stringify(savedData)); - + // Also set up action bar preferences const actionBarPrefs = {}; confs.forEach(conf => { @@ -99,9 +99,9 @@ export async function setupSavedConferences(page, conferences) { * Wait for notification toast to appear */ export async function waitForToast(page, timeout = 5000) { - return await page.waitForSelector('.toast', { + return await page.waitForSelector('.toast', { state: 'visible', - timeout + timeout }); } @@ -165,7 +165,7 @@ export async function navigateToSection(page, section) { 'about': '/about', 'calendar': '/calendar' }; - + const path = sectionMap[section] || section; await page.goto(path); await page.waitForLoadState('networkidle'); @@ -177,11 +177,11 @@ export async function navigateToSection(page, section) { export async function searchConferences(page, query) { // Navigate to search page await navigateToSection(page, 'search'); - + // Enter search query const searchInput = page.locator('#search-input, input[type="search"]').first(); await searchInput.fill(query); - + // Wait for search results to update await page.waitForTimeout(500); } @@ -196,12 +196,12 @@ export async function applyFilters(page, filters) { await page.locator(`input[value="${topic}"]`).check(); } } - + // Format filter if (filters.format) { await page.locator(`input[value="${filters.format}"]`).check(); } - + // Date range filter if (filters.dateRange) { if (filters.dateRange.start) { @@ -211,7 +211,7 @@ export async function applyFilters(page, filters) { await page.locator('input[name="end-date"]').fill(filters.dateRange.end); } } - + // Wait for filters to apply await page.waitForTimeout(500); } @@ -223,7 +223,7 @@ export function createMockConference(overrides = {}) { const baseDate = new Date(); const cfpDate = new Date(baseDate); cfpDate.setDate(cfpDate.getDate() + 30); - + return { id: `test-conf-${Date.now()}`, conference: 'Test Conference', @@ -243,7 +243,7 @@ export function createMockConference(overrides = {}) { export async function waitForPageReady(page) { await page.waitForLoadState('networkidle'); await page.waitForFunction(() => document.readyState === 'complete'); - + // Wait for jQuery to be ready if it exists await page.waitForFunction(() => { return typeof jQuery === 'undefined' || jQuery.isReady; @@ -268,7 +268,7 @@ export async function isInViewport(page, selector) { return await page.evaluate((sel) => { const element = document.querySelector(sel); if (!element) return false; - + const rect = element.getBoundingClientRect(); return ( rect.top >= 0 && @@ -277,4 +277,4 @@ export async function isInViewport(page, selector) { rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); }, selector); -} \ No newline at end of file +} diff --git a/tests/frontend/__mocks__/fileMock.js b/tests/frontend/__mocks__/fileMock.js index 84c1da6fdc..86059f3629 100644 --- a/tests/frontend/__mocks__/fileMock.js +++ b/tests/frontend/__mocks__/fileMock.js @@ -1 +1 @@ -module.exports = 'test-file-stub'; \ No newline at end of file +module.exports = 'test-file-stub'; diff --git a/tests/frontend/__mocks__/styleMock.js b/tests/frontend/__mocks__/styleMock.js index a099545376..f053ebf797 100644 --- a/tests/frontend/__mocks__/styleMock.js +++ b/tests/frontend/__mocks__/styleMock.js @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/tests/frontend/setup.js b/tests/frontend/setup.js index 3b223b124d..b847404f7f 100644 --- a/tests/frontend/setup.js +++ b/tests/frontend/setup.js @@ -32,7 +32,7 @@ expect.extend({ }; } }, - + toHaveBeenCalledWithDate(received, expectedDate, tolerance = 1000) { const calls = received.mock.calls; const pass = calls.some(call => { @@ -43,7 +43,7 @@ expect.extend({ } return false; }); - + if (pass) { return { message: () => `expected not to be called with date near ${expectedDate}`, @@ -62,19 +62,19 @@ expect.extend({ afterEach(() => { // Clear all timers jest.clearAllTimers(); - + // Clear localStorage localStorage.clear(); sessionStorage.clear(); - + // Clear all mocks jest.clearAllMocks(); - + // Reset document body document.body.innerHTML = ''; - + // Remove any event listeners const oldElem = document.body; const newElem = oldElem.cloneNode(true); oldElem.parentNode.replaceChild(newElem, oldElem); -}); \ No newline at end of file +}); diff --git a/tests/frontend/unit/countdown-simple.test.js b/tests/frontend/unit/countdown-simple.test.js index 56d5692b63..65bcbef5d8 100644 --- a/tests/frontend/unit/countdown-simple.test.js +++ b/tests/frontend/unit/countdown-simple.test.js @@ -8,69 +8,69 @@ import { createConferenceWithDeadline, setupConferenceDOM } from '../utils/dataH describe('Countdown Timer System', () => { let timerController; let luxonMock; - + beforeEach(() => { timerController = new TimerController(); timerController.setCurrentTime('2024-01-15 12:00:00'); - + // Mock Luxon luxonMock = mockLuxonDateTime(); - + // Clear any existing intervals jest.clearAllTimers(); }); - + afterEach(() => { timerController.cleanup(); }); - + describe('Timer Initialization', () => { test('initializes countdown timer on page load', () => { document.body.innerHTML = ` -
`; - + // Load the countdown script jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Should have set up an interval expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 1000); }); - + test('updates all countdown elements every second', () => { // Set up multiple countdowns document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Advance timer by 1 second timerController.advanceTime(1000); - + // Both elements should have content const cd1 = document.getElementById('cd1'); const cd2 = document.getElementById('cd2'); - + expect(cd1.textContent).toBeTruthy(); expect(cd2.textContent).toBeTruthy(); }); }); - + describe('Countdown Display Formats', () => { beforeEach(() => { // Mock Luxon DateTime more specifically for these tests @@ -84,7 +84,7 @@ describe('Countdown Timer System', () => { toMillis: () => (days * 24 * 60 * 60 + hours * 60 * 60 + minutes * 60 + seconds) * 1000 })) }); - + window.luxon = { DateTime: { now: jest.fn(() => ({ toMillis: () => Date.now() })), @@ -93,38 +93,38 @@ describe('Countdown Timer System', () => { } }; }); - + test('displays full format for regular countdown', () => { document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('regular'); expect(element.textContent).toMatch(/\d+ days \d+h \d+m \d+s/); }); - + test('displays compact format for small countdown', () => { document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('small'); expect(element.textContent).toMatch(/\d+d \d{2}:\d{2}:\d{2}/); }); }); - + describe('Deadline States', () => { test('shows "Deadline passed" for past deadlines', () => { // Mock Luxon to return negative diff @@ -153,22 +153,22 @@ describe('Countdown Timer System', () => { })) } }; - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('past'); expect(element.textContent).toContain('Deadline passed'); expect(element).toHaveClass('deadline-passed'); }); - + test('shows "Passed" for small countdown past deadline', () => { // Mock Luxon to return negative diff window.luxon = { @@ -188,92 +188,92 @@ describe('Countdown Timer System', () => { })) } }; - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('past-small'); expect(element.textContent).toBe('Passed'); }); - + test('skips TBA deadlines', () => { document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('tba'); expect(element.textContent).toBe(''); }); - + test('skips Cancelled deadlines', () => { document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('cancelled'); expect(element.textContent).toBe(''); }); }); - + describe('Timezone Handling', () => { test('uses specified timezone for countdown', () => { const fromSQLSpy = jest.spyOn(window.luxon.DateTime, 'fromSQL'); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + expect(fromSQLSpy).toHaveBeenCalledWith( '2024-01-22 23:59:59', expect.objectContaining({ zone: 'America/New_York' }) ); }); - + test('defaults to UTC-12 (AoE) if no timezone specified', () => { const fromSQLSpy = jest.spyOn(window.luxon.DateTime, 'fromSQL'); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + expect(fromSQLSpy).toHaveBeenCalledWith( '2024-01-22 23:59:59', expect.objectContaining({ zone: 'UTC-12' }) ); }); - + test('falls back to system timezone on invalid timezone', () => { // Mock invalid timezone handling let callCount = 0; @@ -290,73 +290,73 @@ describe('Countdown Timer System', () => { })) }; }); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Should try multiple times expect(window.luxon.DateTime.fromSQL).toHaveBeenCalledTimes(2); }); }); - + describe('Date Format Parsing', () => { test('parses SQL format dates', () => { const fromSQLSpy = jest.spyOn(window.luxon.DateTime, 'fromSQL'); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + expect(fromSQLSpy).toHaveBeenCalled(); }); - + test('falls back to ISO format if SQL parsing fails', () => { // Mock to make fromSQL return invalid window.luxon.DateTime.fromSQL = jest.fn(() => ({ invalid: true })); const fromISOSpy = jest.spyOn(window.luxon.DateTime, 'fromISO'); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + expect(fromISOSpy).toHaveBeenCalled(); }); - + test('shows error for invalid date formats', () => { // Mock both parsing methods to fail window.luxon.DateTime.fromSQL = jest.fn(() => ({ invalid: true })); window.luxon.DateTime.fromISO = jest.fn(() => ({ invalid: true })); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('invalid'); expect(element.textContent).toBe('Invalid date'); expect(console.warn).toHaveBeenCalledWith( @@ -365,7 +365,7 @@ describe('Countdown Timer System', () => { ); }); }); - + describe('Performance and Memory', () => { test('uses single shared timer for all countdowns', () => { document.body.innerHTML = ` @@ -376,102 +376,102 @@ describe('Countdown Timer System', () => {
`).join('')} `; - + const setIntervalSpy = jest.spyOn(global, 'setInterval'); - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Should only create one interval expect(setIntervalSpy).toHaveBeenCalledTimes(1); }); - + test('clears timer on page unload', () => { const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Simulate page unload const unloadEvent = new Event('beforeunload'); window.dispatchEvent(unloadEvent); - + expect(clearIntervalSpy).toHaveBeenCalled(); }); - + test('handles dynamic countdown addition', () => { document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + // Add new countdown dynamically const newCountdown = document.createElement('div'); newCountdown.className = 'countdown-display'; newCountdown.id = 'dynamic'; newCountdown.dataset.deadline = '2024-01-25 23:59:59'; document.body.appendChild(newCountdown); - + // Advance timer timerController.advanceTime(1000); - + // New countdown should also be updated expect(document.getElementById('dynamic').textContent).toBeTruthy(); }); }); - + describe('Edge Cases', () => { test('handles missing Luxon library gracefully', () => { // Remove Luxon delete window.luxon; - + document.body.innerHTML = `
`; - + // Should not throw error expect(() => { jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); }).not.toThrow(); - + expect(console.error).toHaveBeenCalledWith( 'Luxon DateTime not available. Countdowns disabled.' ); }); - + test('handles error in date parsing gracefully', () => { // Mock to throw error window.luxon.DateTime.fromSQL = jest.fn(() => { throw new Error('Parse error'); }); - + document.body.innerHTML = `
`; - + jest.isolateModules(() => { require('../../../static/js/countdown-simple.js'); }); - + const element = document.getElementById('error'); expect(element.textContent).toBe('Error'); expect(console.error).toHaveBeenCalledWith( @@ -480,4 +480,4 @@ describe('Countdown Timer System', () => { ); }); }); -}); \ No newline at end of file +}); diff --git a/tests/frontend/unit/notifications.test.js b/tests/frontend/unit/notifications.test.js index 192a9a03b8..8cfda3a038 100644 --- a/tests/frontend/unit/notifications.test.js +++ b/tests/frontend/unit/notifications.test.js @@ -2,19 +2,19 @@ * Tests for NotificationManager */ -import { - mockNotificationAPI, - mockStore, +import { + mockNotificationAPI, + mockStore, TimerController, mockBootstrapModal, - mockPageVisibility + mockPageVisibility } from '../utils/mockHelpers'; -import { - createConferenceWithDeadline, +import { + createConferenceWithDeadline, createSavedConferences, setupConferenceDOM, - createConferenceSet + createConferenceSet } from '../utils/dataHelpers'; // We'll load the actual file in the test @@ -25,7 +25,7 @@ describe('NotificationManager', () => { let storeMock; let timerController; let pageVisibility; - + beforeEach(() => { // Set up mocks notificationMock = mockNotificationAPI('default'); @@ -33,189 +33,189 @@ describe('NotificationManager', () => { timerController = new TimerController(); pageVisibility = mockPageVisibility(true); mockBootstrapModal(); - + // Set current time timerController.setCurrentTime('2024-01-15 12:00:00'); - + // Load NotificationManager (it's an IIFE so it auto-initializes) jest.isolateModules(() => { require('../../../static/js/notifications.js'); NotificationManager = window.NotificationManager; }); }); - + afterEach(() => { timerController.cleanup(); notificationMock.clearInstances(); }); - + describe('Browser Support', () => { test('detects browser notification support', () => { NotificationManager.checkBrowserSupport(); expect(console.log).toHaveBeenCalledWith('Browser supports notifications'); }); - + test('shows prompt when permission is default', () => { document.body.innerHTML = ''; notificationMock.permission = 'default'; - + NotificationManager.checkBrowserSupport(); - + const prompt = document.getElementById('notification-prompt'); expect(prompt.style.display).not.toBe('none'); }); - + test('hides prompt when permission is granted', () => { document.body.innerHTML = '
'; notificationMock.permission = 'granted'; - + NotificationManager.checkBrowserSupport(); - + const prompt = document.getElementById('notification-prompt'); expect(prompt.style.display).toBe('none'); }); }); - + describe('Permission Request', () => { test('requests notification permission', async () => { notificationMock.requestPermission.mockResolvedValue('granted'); - + const result = await NotificationManager.requestPermission(); - + expect(notificationMock.requestPermission).toHaveBeenCalled(); expect(result).toBe('granted'); }); - + test('shows test notification when permission granted', async () => { notificationMock.requestPermission.mockResolvedValue('granted'); notificationMock.permission = 'granted'; - + await NotificationManager.requestPermission(); - + // Should create a test notification expect(notificationMock.instances.length).toBe(1); expect(notificationMock.instances[0].title).toBe('Python Deadlines'); expect(notificationMock.instances[0].body).toContain('Notifications are now enabled'); }); - + test('handles permission denial', async () => { notificationMock.requestPermission.mockResolvedValue('denied'); document.body.innerHTML = '
'; - + const result = await NotificationManager.requestPermission(); - + expect(result).toBe('denied'); expect(document.getElementById('notification-prompt').style.display).toBe('none'); }); }); - + describe('Deadline Notifications', () => { beforeEach(() => { notificationMock.permission = 'granted'; - + // Set up default notification settings storeMock.set('pythondeadlines-notification-settings', { days: [14, 7, 3, 1], enabled: true }); }); - + test('sends notification 7 days before deadline', () => { const conf = createConferenceWithDeadline(7, { id: 'test-7day' }); const saved = createSavedConferences([conf]); storeMock.set('pythondeadlines-saved-conferences', saved); - + NotificationManager.checkUpcomingDeadlines(); - + expect(notificationMock.instances.length).toBe(1); const notification = notificationMock.instances[0]; expect(notification.body).toContain('7 days until CFP deadline'); }); - + test('sends notification 3 days before deadline', () => { const conf = createConferenceWithDeadline(3, { id: 'test-3day' }); const saved = createSavedConferences([conf]); storeMock.set('pythondeadlines-saved-conferences', saved); - + NotificationManager.checkUpcomingDeadlines(); - + expect(notificationMock.instances.length).toBe(1); const notification = notificationMock.instances[0]; expect(notification.body).toContain('3 days until CFP deadline'); }); - + test('sends urgent notification 1 day before deadline', () => { const conf = createConferenceWithDeadline(1, { id: 'test-1day' }); const saved = createSavedConferences([conf]); storeMock.set('pythondeadlines-saved-conferences', saved); - + NotificationManager.checkUpcomingDeadlines(); - + expect(notificationMock.instances.length).toBe(1); const notification = notificationMock.instances[0]; expect(notification.body).toContain('1 day until CFP deadline'); expect(notification.requireInteraction).toBe(true); }); - + test('sends notification for deadline today', () => { const conf = createConferenceWithDeadline(0, { id: 'test-today' }); const saved = createSavedConferences([conf]); storeMock.set('pythondeadlines-saved-conferences', saved); - + NotificationManager.checkUpcomingDeadlines(); - + expect(notificationMock.instances.length).toBe(1); const notification = notificationMock.instances[0]; expect(notification.body).toContain('CFP deadline is TODAY'); expect(notification.requireInteraction).toBe(true); }); - + test('does not send duplicate notifications', () => { const conf = createConferenceWithDeadline(7, { id: 'test-no-dup' }); const saved = createSavedConferences([conf]); storeMock.set('pythondeadlines-saved-conferences', saved); - + // First check NotificationManager.checkUpcomingDeadlines(); expect(notificationMock.instances.length).toBe(1); - + // Reset instances notificationMock.clearInstances(); - + // Second check - should not send again NotificationManager.checkUpcomingDeadlines(); expect(notificationMock.instances.length).toBe(0); }); - + test('respects notification day settings', () => { // Only notify at 3 and 1 day marks storeMock.set('pythondeadlines-notification-settings', { days: [3, 1], enabled: true }); - + const conf7 = createConferenceWithDeadline(7, { id: 'test-7' }); const conf3 = createConferenceWithDeadline(3, { id: 'test-3' }); const saved = createSavedConferences([conf7, conf3]); storeMock.set('pythondeadlines-saved-conferences', saved); - + NotificationManager.loadSettings(); NotificationManager.checkUpcomingDeadlines(); - + // Should only notify for 3-day conference expect(notificationMock.instances.length).toBe(1); expect(notificationMock.instances[0].body).toContain('3 days'); }); }); - + describe('Action Bar Integration', () => { beforeEach(() => { notificationMock.permission = 'granted'; - + // Set up conferences in DOM const conferences = createConferenceSet(); setupConferenceDOM(Object.values(conferences)); - + // Set up action bar preferences localStorage.setItem('pydeadlines_actionBarPrefs', JSON.stringify({ 'conf-7days': { save: true }, @@ -223,55 +223,55 @@ describe('NotificationManager', () => { 'conf-1day': { save: true } })); }); - + test('checks action bar notification preferences', () => { // Reset last check time to allow checking localStorage.removeItem('pydeadlines_lastNotifyCheck'); - + NotificationManager.checkActionBarNotifications(); - + // Should create notifications for saved conferences - const notifications = notificationMock.instances.filter(n => + const notifications = notificationMock.instances.filter(n => n.title === 'Python Deadlines Reminder' ); - + expect(notifications.length).toBeGreaterThan(0); }); - + test('respects 4-hour check interval', () => { const now = Date.now(); localStorage.setItem('pydeadlines_lastNotifyCheck', (now - 2 * 60 * 60 * 1000).toString()); - + NotificationManager.checkActionBarNotifications(); - + // Should not check (less than 4 hours) expect(notificationMock.instances.length).toBe(0); }); - + test('handles notification click to scroll to conference', () => { localStorage.removeItem('pydeadlines_lastNotifyCheck'); - + const scrollSpy = jest.spyOn(Element.prototype, 'scrollIntoView'); - + NotificationManager.checkActionBarNotifications(); - + // Simulate click on notification if (notificationMock.instances.length > 0) { const notification = notificationMock.instances[0]; notification.onclick(); - + expect(window.focus).toHaveBeenCalled(); expect(notification.close).toHaveBeenCalled(); } - + scrollSpy.mockRestore(); }); }); - + describe('Settings Management', () => { test('loads default settings', () => { NotificationManager.loadSettings(); - + expect(NotificationManager.settings).toEqual({ days: [14, 7, 3, 1], newEditions: true, @@ -281,15 +281,15 @@ describe('NotificationManager', () => { emailEnabled: false }); }); - + test('saves settings to store', () => { NotificationManager.settings = { days: [7, 1], enabled: true }; - + NotificationManager.saveSettings(); - + expect(storeMock.set).toHaveBeenCalledWith( 'pythondeadlines-notification-settings', expect.objectContaining({ @@ -298,7 +298,7 @@ describe('NotificationManager', () => { }) ); }); - + test('applies settings to UI elements', () => { document.body.innerHTML = ` @@ -307,59 +307,59 @@ describe('NotificationManager', () => { `; - + NotificationManager.settings = { days: [7, 1], newEditions: true, autoFavorite: false }; - + NotificationManager.applySettingsToUI(); - + const checkboxes = document.querySelectorAll('.notify-days:checked'); expect(checkboxes.length).toBe(2); expect(document.getElementById('notify-new-editions').checked).toBe(true); expect(document.getElementById('auto-favorite-series').checked).toBe(false); }); }); - + describe('Periodic Checks', () => { test('schedules periodic checks', () => { const setIntervalSpy = jest.spyOn(global, 'setInterval'); - + NotificationManager.schedulePeriodicChecks(); - + // Should set up hourly check expect(setIntervalSpy).toHaveBeenCalledWith( expect.any(Function), 60 * 60 * 1000 ); }); - + test('checks on page visibility change', () => { const checkSpy = jest.spyOn(NotificationManager, 'checkUpcomingDeadlines'); - + NotificationManager.schedulePeriodicChecks(); - + // Simulate page becoming visible pageVisibility.setVisible(false); pageVisibility.setVisible(true); - + expect(checkSpy).toHaveBeenCalled(); }); - + test('checks on window focus', () => { const checkSpy = jest.spyOn(NotificationManager, 'checkUpcomingDeadlines'); - + NotificationManager.schedulePeriodicChecks(); - + // Simulate window focus window.focus(); - + expect(checkSpy).toHaveBeenCalled(); }); }); - + describe('Toast Notifications', () => { test('shows in-app notification toast', () => { NotificationManager.showInAppNotification( @@ -367,40 +367,40 @@ describe('NotificationManager', () => { 'Test Message', 'warning' ); - + const toast = document.querySelector('.toast'); expect(toast).toBeTruthy(); expect(toast.querySelector('.toast-header')).toHaveTextContent('Test Title'); expect(toast.querySelector('.toast-body')).toHaveTextContent('Test Message'); expect(toast.querySelector('.toast-header')).toHaveClass('bg-warning'); }); - + test('creates toast container if not exists', () => { // Remove any existing container const existing = document.getElementById('toast-container'); if (existing) existing.remove(); - + NotificationManager.showInAppNotification('Test', 'Message'); - + expect(document.getElementById('toast-container')).toBeTruthy(); }); }); - + describe('Notification Cleanup', () => { test('cleans up old notifications after 30 days', () => { const oldDate = new Date(); oldDate.setDate(oldDate.getDate() - 35); - + storeMock.set('pythondeadlines-notified-deadlines', { 'old-notification': oldDate.toISOString(), 'recent-notification': new Date().toISOString() }); - + NotificationManager.checkUpcomingDeadlines(); - + const notified = storeMock.get('pythondeadlines-notified-deadlines'); expect(notified['old-notification']).toBeUndefined(); expect(notified['recent-notification']).toBeDefined(); }); }); -}); \ No newline at end of file +}); diff --git a/tests/frontend/utils/dataHelpers.js b/tests/frontend/utils/dataHelpers.js index 3c38353415..2453c82db5 100644 --- a/tests/frontend/utils/dataHelpers.js +++ b/tests/frontend/utils/dataHelpers.js @@ -9,13 +9,13 @@ export function createMockConference(overrides = {}) { const baseDate = new Date(); const cfpDate = new Date(baseDate); cfpDate.setDate(cfpDate.getDate() + 30); // CFP 30 days from now - + const startDate = new Date(cfpDate); startDate.setDate(startDate.getDate() + 60); // Conference 60 days after CFP - + const endDate = new Date(startDate); endDate.setDate(endDate.getDate() + 3); // 3-day conference - + return { id: `conf-${Math.random().toString(36).substr(2, 9)}`, conference: 'PyCon Test', @@ -46,7 +46,7 @@ export function createConferenceWithDeadline(daysUntilDeadline, overrides = {}) const cfpDate = new Date(); cfpDate.setDate(cfpDate.getDate() + daysUntilDeadline); cfpDate.setHours(23, 59, 59, 0); - + return createMockConference({ cfp: cfpDate.toISOString().replace('T', ' ').split('.')[0], ...overrides @@ -59,7 +59,7 @@ export function createConferenceWithDeadline(daysUntilDeadline, overrides = {}) export function createPastConference(daysPast = 30, overrides = {}) { const cfpDate = new Date(); cfpDate.setDate(cfpDate.getDate() - daysPast); - + return createMockConference({ cfp: cfpDate.toISOString().replace('T', ' ').split('.')[0], ...overrides @@ -88,7 +88,7 @@ export function createConferenceDOM(conference) { div.dataset.hasFinaid = conference.hasFinaid; div.dataset.hasWorkshop = conference.hasWorkshop; div.dataset.hasSponsor = conference.hasSponsor; - + div.innerHTML = `
@@ -101,7 +101,7 @@ export function createConferenceDOM(conference) {
CFP: ${conference.cfp} - @@ -111,7 +111,7 @@ export function createConferenceDOM(conference) { -
`; - + return div; } @@ -129,12 +129,12 @@ export function createConferenceDOM(conference) { */ export function createConferenceSet() { return { - upcoming7Days: createConferenceWithDeadline(7, { + upcoming7Days: createConferenceWithDeadline(7, { id: 'conf-7days', conference: 'PyCon Upcoming 7' }), upcoming3Days: createConferenceWithDeadline(3, { - id: 'conf-3days', + id: 'conf-3days', conference: 'PyCon Upcoming 3' }), upcoming1Day: createConferenceWithDeadline(1, { @@ -189,22 +189,22 @@ export function setupConferenceDOM(conferences) { const container = document.createElement('div'); container.id = 'conference-list'; container.className = 'conference-list'; - + conferences.forEach(conf => { container.appendChild(createConferenceDOM(conf)); }); - + document.body.appendChild(container); - + // Add other required DOM elements const notificationPrompt = document.createElement('div'); notificationPrompt.id = 'notification-prompt'; notificationPrompt.style.display = 'none'; document.body.appendChild(notificationPrompt); - + const toastContainer = document.createElement('div'); toastContainer.id = 'toast-container'; document.body.appendChild(toastContainer); - + return container; -} \ No newline at end of file +} diff --git a/tests/frontend/utils/globalSetup.js b/tests/frontend/utils/globalSetup.js index f4b8412d1c..948528e132 100644 --- a/tests/frontend/utils/globalSetup.js +++ b/tests/frontend/utils/globalSetup.js @@ -75,4 +75,4 @@ global.store = { clear: jest.fn(() => { localStorage.clear(); }) -}; \ No newline at end of file +}; diff --git a/tests/frontend/utils/jsTransform.js b/tests/frontend/utils/jsTransform.js index 85c0c96d6f..e151ed79aa 100644 --- a/tests/frontend/utils/jsTransform.js +++ b/tests/frontend/utils/jsTransform.js @@ -9,29 +9,29 @@ module.exports = { if (sourcePath.includes('node_modules') || sourcePath.includes('.min.js')) { return { code: sourceText }; } - + // Handle IIFE patterns - unwrap them for testing let transformed = sourceText; - + // Remove IIFE wrapper if present const iifePattern = /^\s*\(\s*function\s*\(\s*\)\s*{([\s\S]*?)}\s*\)\s*\(\s*\)\s*;?\s*$/; const iifeMatch = sourceText.match(iifePattern); if (iifeMatch) { transformed = iifeMatch[1]; } - + // Remove jQuery document ready wrapper if present const jqueryReadyPattern = /\$\(document\)\.ready\s*\(\s*function\s*\(\s*\)\s*{([\s\S]*?)}\s*\)\s*;?/; const jqueryMatch = transformed.match(jqueryReadyPattern); if (jqueryMatch) { // Keep the initialization code but make it callable - transformed = transformed.replace(jqueryReadyPattern, + transformed = transformed.replace(jqueryReadyPattern, `if (typeof window !== 'undefined' && window.IS_TESTING !== true) { $(document).ready(function() {${jqueryMatch[1]}}); }` ); } - + // Export any global objects for testing const globalObjects = ['NotificationManager', 'FavoritesManager', 'ConferenceStateManager']; globalObjects.forEach(obj => { @@ -39,13 +39,13 @@ module.exports = { transformed += `\nif (typeof module !== 'undefined' && module.exports) { module.exports.${obj} = ${obj}; }`; } }); - + // Ensure 'use strict' is at the top if present if (transformed.includes("'use strict'")) { transformed = transformed.replace(/['"]use strict['"];?/g, ''); transformed = "'use strict';\n" + transformed; } - + return { code: transformed }; }, -}; \ No newline at end of file +}; diff --git a/tests/frontend/utils/mockHelpers.js b/tests/frontend/utils/mockHelpers.js index 42f88bf9b9..8c2bda172d 100644 --- a/tests/frontend/utils/mockHelpers.js +++ b/tests/frontend/utils/mockHelpers.js @@ -18,22 +18,22 @@ export function mockNotificationAPI(permission = 'default') { this.onclose = null; this.onerror = null; this.close = jest.fn(); - + // Track created notifications NotificationMock.instances.push(this); }); - + NotificationMock.instances = []; NotificationMock.permission = permission; NotificationMock.requestPermission = jest.fn().mockResolvedValue(permission); - + // Helper to clear instances NotificationMock.clearInstances = () => { NotificationMock.instances = []; }; - + global.Notification = NotificationMock; - + return NotificationMock; } @@ -42,7 +42,7 @@ export function mockNotificationAPI(permission = 'default') { */ export function mockLocalStorage() { const storage = {}; - + const localStorageMock = { getItem: jest.fn((key) => storage[key] || null), setItem: jest.fn((key, value) => { @@ -64,12 +64,12 @@ export function mockLocalStorage() { // Helper to get raw storage for assertions _getStorage: () => ({ ...storage }) }; - + Object.defineProperty(window, 'localStorage', { value: localStorageMock, writable: true }); - + return localStorageMock; } @@ -78,7 +78,7 @@ export function mockLocalStorage() { */ export function mockStore() { const storage = new Map(); - + const storeMock = { get: jest.fn((key) => storage.get(key)), set: jest.fn((key, value) => storage.set(key, value)), @@ -88,10 +88,10 @@ export function mockStore() { _getAll: () => Object.fromEntries(storage), _reset: () => storage.clear() }; - + global.store = storeMock; window.store = storeMock; - + return storeMock; } @@ -103,43 +103,43 @@ export class TimerController { this.currentTime = new Date(); jest.useFakeTimers(); } - + setCurrentTime(date) { this.currentTime = date instanceof Date ? date : new Date(date); jest.setSystemTime(this.currentTime); return this; } - + advanceTime(ms) { this.currentTime = new Date(this.currentTime.getTime() + ms); jest.setSystemTime(this.currentTime); jest.advanceTimersByTime(ms); return this; } - + advanceDays(days) { return this.advanceTime(days * 24 * 60 * 60 * 1000); } - + advanceToNextInterval() { jest.advanceTimersToNextTimer(); return this; } - + runAllTimers() { jest.runAllTimers(); return this; } - + runOnlyPendingTimers() { jest.runOnlyPendingTimers(); return this; } - + getCurrentTime() { return new Date(this.currentTime); } - + cleanup() { jest.useRealTimers(); } @@ -159,7 +159,7 @@ export function mockBootstrapModal() { } return this; }); - + $.fn.toast = jest.fn(function(action) { if (action === 'show') { $(this).addClass('show'); @@ -186,22 +186,22 @@ export function mockPageVisibility(isVisible = true) { configurable: true, get: () => !isVisible }); - + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => isVisible ? 'visible' : 'hidden' }); - + window.focus = jest.fn(() => { const event = new Event('focus'); window.dispatchEvent(event); }); - + window.blur = jest.fn(() => { const event = new Event('blur'); window.dispatchEvent(event); }); - + return { setVisible: (visible) => { isVisible = visible; @@ -254,4 +254,4 @@ export function mockLuxonDateTime() { }; } return window.luxon; -} \ No newline at end of file +}