Skip to content

Conversation

@vpetersson
Copy link
Contributor

@vpetersson vpetersson commented Dec 6, 2025

User description

Still requires testing, but the foundation is in here.


PR Type

Tests, Enhancement


Description

  • Add comprehensive CAP v1.2 parser tests

  • Implement robust CAP feed fetcher

  • Add fetcher unit tests with caching

  • Configure project and tooling


Diagram Walkthrough

flowchart LR
  ParserTests["CAP parser tests"] -- validate XML parsing --> MainParser["src/main.ts"]
  Fetcher["CAPFetcher (fetcher.ts)"] -- periodic fetch, cache, retry --> Network["HTTP fetch"]
  FetcherTests["Fetcher unit tests"] -- mock fetch/localStorage --> Fetcher
  AppShell["index.html + assets"] -- loads --> Bundle["static/js/main.js"]
Loading

File Walkthrough

Relevant files
Tests
3 files
index.test.ts
Comprehensive CAP v1.2 parser test suite                                 
+1858/-0
fetcher.test.ts
Unit tests for CAPFetcher caching and retries                       
+612/-0 
test.cap
Test CAP sample file                                                                         
+29/-0   
Enhancement
5 files
fetcher.ts
Implement CAP feed fetcher with cache and backoff               
+348/-0 
index.html
App HTML shell and script includes                                             
+16/-0   
index.ts
Edge app bootstrap and integration                                             
+546/-0 
main.ts
CAP XML parsing and app logic                                                       
+490/-0 
main.js
Compiled frontend logic bundle                                                     
+6118/-0
Configuration changes
7 files
eslint.config.ts
Add TypeScript ESLint configuration                                           
+18/-0   
tailwind.config.js
Tailwind configuration with extended breakpoints                 
+26/-0   
tsconfig.json
TypeScript compiler configuration for app                               
+17/-0   
.prettierrc
Prettier formatting configuration                                               
+6/-0     
package.json
Project package metadata and scripts                                         
+39/-0   
screenly.yml
Screenly app manifest                                                                       
+58/-0   
screenly_qc.yml
Screenly QC configuration                                                               
+58/-0   
Documentation
7 files
demo-6-shooter.cap
Add demo CAP alert: active shooter scenario                           
+47/-0   
demo-5-hazmat.cap
Add demo CAP alert: hazmat spill                                                 
+48/-0   
demo-3-flood.cap
Add demo CAP alert: flood warning                                               
+43/-0   
demo-4-earthquake.cap
Add demo CAP alert: earthquake advisory                                   
+47/-0   
demo-1-tornado.cap
Add demo CAP alert: tornado warning                                           
+38/-0   
demo-2-fire.cap
Add demo CAP alert: fire emergency                                             
+36/-0   
README.md
Documentation for CAP Alerting app                                             
+35/-0   
Formatting
2 files
style.css
Compiled stylesheet for app UI                                                     
+2/-0     
input.css
Tailwind input styles                                                                       
+135/-0 

@github-actions
Copy link

github-actions bot commented Dec 6, 2025

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Test Robustness

Several expectations assume numeric parsing (e.g., geocode value to number, resource size to number). Confirm the parser intentionally coerces these fields and handles leading zeros without losing semantic meaning (e.g., FIPS codes).

  const area = alerts[0].infos[0].areas[0]
  expect(area.geocode).toBeDefined()
  expect(area.geocode.valueName).toBe('FIPS6')
  expect(area.geocode.value).toBe(6017)
})
Global Mocks

Tests overwrite globals like window, fetch, and localStorage; ensure isolation/cleanup across runs and environments (Bun/node) to prevent leakage and flaky behavior.

  // Setup mocks
  global.localStorage = localStorageMock as any
  global.fetch = mockFetch as any
  global.window = { setInterval, clearInterval, setTimeout, clearTimeout } as any

  // Clear localStorage before each test
  localStorageMock.clear()

  // Reset fetch mock
  mockFetch.mockReset()
})

afterEach(() => {
  mockFetch.mockRestore()
})
Backoff Timing Flakiness

Exponential backoff assertion relies on elapsed wall time with jitter; this can be flaky on slower CI. Consider loosening thresholds or mocking timers.

it('should use exponential backoff', async () => {
  mockFetch.mockRejectedValue(new Error('Network error'))

  const fetcher = new CAPFetcher({
    feedUrl: 'https://example.com/feed.xml',
    corsProxyUrl: 'https://proxy.com',
    maxRetries: 3,
    initialRetryDelay: 100,
  })

  const updateCallback = mock()
  const startTime = Date.now()

  fetcher.start(updateCallback)

  await waitFor(() => mockFetch.mock.calls.length >= 3, 3000)

  const elapsed = Date.now() - startTime

  // Should take at least 100ms (1st retry) + 200ms (2nd retry) = 300ms
  // With jitter, it could be slightly less, so check for at least 200ms
  expect(elapsed).toBeGreaterThan(200)

  fetcher.stop()
})

@github-actions
Copy link

github-actions bot commented Dec 6, 2025

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Normalize proxy URL joining

Concatenating the proxy and full URL with a slash can produce double slashes or
malformed URLs if corsProxyUrl already ends with a slash. Normalize the join to
avoid fetch failures due to bad URLs.

edge-apps/cap-alerting/src/fetcher.ts [274-276]

 if (this.config.feedUrl.match(/^https?:/)) {
-  url = `${this.config.corsProxyUrl}/${this.config.feedUrl}`
+  const proxy = (this.config.corsProxyUrl || '').replace(/\/+$/, '')
+  const target = this.config.feedUrl.replace(/^\/+/, '')
+  url = `${proxy}/${target}`
 }
Suggestion importance[1-10]: 8

__

Why: Normalizing the proxy and target URL prevents malformed URLs (double slashes) and potential fetch failures; it's a precise, high-impact fix in the new fetcher code.

Medium
Stabilize timing-based test assertions

Make the time-based assertion resilient to scheduler variability by spying on
setTimeout calls or summing the scheduled delays rather than wall-clock.
Timing-based tests are flaky under load or CI and can intermittently fail.

edge-apps/cap-alerting/src/fetcher.test.ts [278-301]

 it('should use exponential backoff', async () => {
   mockFetch.mockRejectedValue(new Error('Network error'))
 
   const fetcher = new CAPFetcher({
     feedUrl: 'https://example.com/feed.xml',
     corsProxyUrl: 'https://proxy.com',
     maxRetries: 3,
     initialRetryDelay: 100,
   })
 
   const updateCallback = mock()
-  const startTime = Date.now()
-  
+  const timeouts: number[] = []
+  const originalSetTimeout = global.setTimeout
+  ;(global as any).setTimeout = (fn: (...args: any[]) => any, delay?: number, ...args: any[]) => {
+    if (typeof delay === 'number') timeouts.push(delay)
+    return originalSetTimeout(fn, delay as number, ...args)
+  }
+
   fetcher.start(updateCallback)
-
   await waitFor(() => mockFetch.mock.calls.length >= 3, 3000)
 
-  const elapsed = Date.now() - startTime
-  
-  // Should take at least 100ms (1st retry) + 200ms (2nd retry) = 300ms
-  // With jitter, it could be slightly less, so check for at least 200ms
-  expect(elapsed).toBeGreaterThan(200)
+  expect(timeouts.some(d => d >= 100)).toBe(true)
+  expect(timeouts.some(d => d >= 200)).toBe(true)
 
   fetcher.stop()
+  ;(global as any).setTimeout = originalSetTimeout
 })
Suggestion importance[1-10]: 7

__

Why: Replacing wall-clock timing with captured setTimeout delays makes the exponential backoff test less flaky. This is relevant to the provided test and improves robustness, though it's an enhancement rather than a critical fix.

Medium
Possible issue
Restore mutated globals after tests

Restore mutated globals like window and localStorage in afterEach to prevent
cross-test contamination. Without restoring, later tests may rely on stale mocks
leading to flaky or misleading results.

edge-apps/cap-alerting/src/fetcher.test.ts [54-56]

+const originalWindow = (global as any).window
+const originalFetch = global.fetch
+const originalLocalStorage = (global as any).localStorage
+
 afterEach(() => {
   mockFetch.mockRestore()
+  ;(global as any).window = originalWindow
+  ;(global as any).fetch = originalFetch
+  ;(global as any).localStorage = originalLocalStorage
+  localStorageMock.clear()
 })
Suggestion importance[1-10]: 7

__

Why: Restoring window, fetch, and localStorage after each test prevents contamination and flakiness. It directly addresses the teardown block and improves test reliability without altering functionality.

Medium
Remove lookbehind from splitter

The regex relies on lookbehind, which is unsupported on some embedded browsers,
causing failures in sentence splitting. Replace with a more compatible splitter that
doesn’t use lookbehind to avoid runtime errors on older WebViews.

edge-apps/cap-alerting/index.ts [217-222]

 function splitIntoSentences(text: string): string[] {
-  return text
-    .split(/(?<=[.!?])\s+/)
-    .map((s) => s.trim())
-    .filter((s) => s.length > 0)
+  // Split on punctuation followed by whitespace without using lookbehind
+  const parts = text.split(/([.!?])\s+/)
+  const sentences: string[] = []
+  for (let i = 0; i < parts.length; i += 2) {
+    const chunk = parts[i] ?? ''
+    const punct = parts[i + 1] ?? ''
+    const s = (chunk + punct).trim()
+    if (s) sentences.push(s)
+  }
+  return sentences
 }
Suggestion importance[1-10]: 7

__

Why: This avoids regex lookbehind that may not be supported in some WebViews, improving robustness with a compatible splitting approach; impact is moderate and change is accurate.

Medium
Avoid clobbering global window

Provide a minimal window object only if it does not already exist to avoid
overwriting the real global in environments where window is present. This prevents
unintended side effects across tests and potential timer API mismatches. Guard the
assignment and restore it in teardown.

edge-apps/cap-alerting/src/fetcher.test.ts [45]

-global.window = { setInterval, clearInterval, setTimeout, clearTimeout } as any
+const originalWindow = (global as any).window
+if (!originalWindow) {
+  ;(global as any).window = { setInterval, clearInterval, setTimeout, clearTimeout }
+}
Suggestion importance[1-10]: 6

__

Why: Guarding against overwriting global.window reduces cross-test side effects and is contextually accurate since the test assigns window. Impact is moderate and correctness is sound, though not critical.

Low
Fix robust keyword highlighting

The current regex uses the word boundary token with phrases containing spaces and
apostrophes, which can fail and cause partial matches (e.g., "NOW" in "KNOWN").
Also, keywords are duplicated and not escaped, risking unintended regex behavior.
Normalize and deduplicate keywords, escape regex specials, and match on non-word
boundaries using lookarounds.

edge-apps/cap-alerting/index.ts [188-215]

 function highlightKeywords(text: string): string {
-  const keywords = [
+  const rawKeywords = [
     'DO NOT',
-    'DON\'T',
-    'DO NOT',
+    "DON'T",
     'IMMEDIATELY',
     'IMMEDIATE',
     'NOW',
     'MOVE TO',
     'EVACUATE',
     'CALL',
     'WARNING',
     'DANGER',
     'SHELTER',
     'TAKE COVER',
     'AVOID',
     'STAY',
     'SEEK',
   ]
-
+  // Deduplicate and escape for regex
+  const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  const keywords = Array.from(new Set(rawKeywords)).map(esc)
   let result = text
-  keywords.forEach((keyword) => {
-    const regex = new RegExp(`\\b(${keyword})\\b`, 'gi')
+  keywords.forEach((kw) => {
+    // Use lookarounds to avoid matching inside larger words and handle spaces/apostrophes
+    const regex = new RegExp(`(?<![A-Za-z0-9])(${kw})(?![A-Za-z0-9])`, 'gi')
     result = result.replace(regex, '<strong>$1</strong>')
   })
-
   return result
 }
Suggestion importance[1-10]: 6

__

Why: The proposal correctly deduplicates and escapes keywords and avoids partial word matches; however, the original use of \b is already reasonable for most cases and the added lookbehind/lookahead may reduce compatibility on older environments.

Low

@nicomiguelino
Copy link
Contributor

As discussed, I'll continue on this PR from this point forward (i.e., refine implementation, write tests, increase coverage).

@nicomiguelino nicomiguelino self-assigned this Dec 8, 2025
Comment on lines +47 to +58
test_mode:
type: string
title: Test Mode
optional: true
default_value: 'false'
help_text: Use bundled test CAP file for testing (true/false).
demo_mode:
type: string
title: Demo Mode
optional: true
default_value: 'false'
help_text: Enable demo mode with realistic CAP alerts (randomly selected). Only active when test_mode is disabled and no feed URL is set (true/false).
Copy link
Contributor

Choose a reason for hiding this comment

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

mode: [production, test, demo]

title: Offline Mode
optional: true
default_value: 'false'
help_text: When enabled, avoid network fetches and use cached data (true/false).
Copy link
Contributor

@renatgalimov renatgalimov Dec 9, 2025

Choose a reason for hiding this comment

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

🤔 Let's ensure that our system can work offline by default. Otherwise, human factors could affect its reliability.

Copy link
Contributor

Choose a reason for hiding this comment

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

libedgeapp

Comment on lines +35 to +40
audio_alert:
type: string
title: Play Audio Alerts
optional: true
default_value: 'false'
help_text: Play audio from CAP resources when available (true/false).
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
audio_alert:
type: string
title: Play Audio Alerts
optional: true
default_value: 'false'
help_text: Play audio from CAP resources when available (true/false).
mute_sound:
type: string
title: Play Audio Alerts
optional: true
default_value: 'false'
help_text: Play audio from CAP resources when available (true/false). Not available for Screenly Anywhere.

Probably, audio alerts should be on by default.
Also, not available on Anywhere yet.

type: string
title: Play Audio Alerts
optional: true
default_value: 'false'
Copy link
Contributor

Choose a reason for hiding this comment

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

Should be on by default.

Comment on lines +41 to +58
offline_mode:
type: string
title: Offline Mode
optional: true
default_value: 'false'
help_text: When enabled, avoid network fetches and use cached data (true/false).
test_mode:
type: string
title: Test Mode
optional: true
default_value: 'false'
help_text: Use bundled test CAP file for testing (true/false).
demo_mode:
type: string
title: Demo Mode
optional: true
default_value: 'false'
help_text: Enable demo mode with realistic CAP alerts (randomly selected). Only active when test_mode is disabled and no feed URL is set (true/false).
Copy link
Contributor

Choose a reason for hiding this comment

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

Make this setting show as a drop-down selector.

Comment on lines +29 to +34
max_alerts:
type: string
title: Maximum Alerts
optional: true
default_value: '3'
help_text: Maximum number of alerts to display simultaneously.
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't like the default. Maybe, we should show as many as the screens allows?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants