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
The change was made by Bun when `bun install` was executed.
- Include `cap-alerting/` in `.gitignore`
- Have the unit tests be run via `bun run test:unit` instead
- Extract common build flags into shared scripts to reduce duplication
- Add build:css:common and build:js:common scripts
- Add watch mode for CSS and JS builds
- Add npm-run-all2 dependency for parallel task execution
- Update isAnywhereScreen to check for empty string or undefined
- Use isAnywhereScreen in cap-alerting demo mode logic
- Extract CAP type definitions into separate types/cap.ts file
- Use getTags() helper instead of direct metadata access
… setting

- Replace separate demo_mode and test_mode boolean settings with single mode enumeration
- Mode supports three values: production, demo, test
- Add CAPMode type for type-safe mode handling
- Update screenly.yml and screenly_qc.yml with mode setting as select type
- Update src/main.ts and index.ts to use CAPMode type
- Delete legacy index.ts entry point
- Delete orphaned CAPFetcher implementation (fetcher.ts)
- Delete comprehensive CAPFetcher test suite (fetcher.test.ts)
- Update index.test.ts to use modern @screenly/edge-apps imports
- Update package.json lint script to reflect active files only
- Create src/fetcher.ts with CAPFetcher class
- Extract test, demo, and live data fetching logic
- Create comprehensive test suite in src/fetcher.test.ts
- Update main.ts to use CAPFetcher instead of inline logic
- Remove DEMO_BASE_URL, fetchCapData, and related inline fetch code
- Update package.json lint script to include fetcher files
…odule

- Create src/parser.ts with parseCap function
- Rename index.test.ts to src/parser.test.ts with CAP parsing tests
- Update src/main.ts to import parseCap from parser module
- Update package.json lint script to reference new parser files
- Rename audio_alert to mute_sound in both screenly.yml and screenly_qc.yml
- Update help_text to use boolean type structure for toggle switch display
- Invert logic in main.ts: playAudio = !getSettingWithDefault(mute_sound, false)
- Update all settings to use structured help_text format
- Add type information (string, number, boolean, select) to all settings
- Remove (true/false) notation from boolean settings
- Ensures consistent UI presentation for all setting types
@renatgalimov renatgalimov requested a review from Copilot December 30, 2025 15:01
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a comprehensive Common Alerting Protocol (CAP) v1.2 edge app for displaying emergency alerts on Screenly digital signage screens. The implementation includes robust XML parsing, caching mechanisms, and multiple operating modes (test, demo, production).

Key changes:

  • Complete CAP v1.2 parser with support for all standard fields and multi-language alerts
  • Fetcher with caching, offline mode, and demo capabilities
  • Responsive UI optimized for digital signage displays using viewport-based typography

Reviewed changes

Copilot reviewed 31 out of 33 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
edge-apps/edge-apps-library/src/utils/metadata.ts Adds isAnywhereScreen() helper to detect Anywhere screens
edge-apps/cap-alerting/src/parser.ts CAP XML parser supporting all v1.2 fields
edge-apps/cap-alerting/src/fetcher.ts Feed fetcher with caching and multiple modes
edge-apps/cap-alerting/src/main.ts Main app logic and alert rendering
edge-apps/cap-alerting/src/utils.ts Utility functions for exit tags and text processing
edge-apps/cap-alerting/src/render.ts Keyword highlighting for emergency instructions
edge-apps/cap-alerting/src/types/cap.ts TypeScript interfaces for CAP data structures
edge-apps/cap-alerting/src/input.css Viewport-optimized styles for digital signage
static/*.cap Demo CAP alert files for various emergency scenarios

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +24
const container = document.getElementById('alerts')
if (!container) return
Copy link
Contributor

Choose a reason for hiding this comment

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

Would this display an error when the debug mode enabled, or will it silently die?

It looks like there could be more similar issues.

Fail fast, at least in debug mode.

Copy link
Contributor

Choose a reason for hiding this comment

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

Not adding a check like this could cause TypeScript build and type-check errors. We could make use of panic-overlay and have this part of the code throw an error before executing return.

A separate pull request could be made for setting up panic-overlay in the edge-apps-library. That way, if we wish to replace panic-overlay with a different library, the migration would be easier (and there's less code duplication).

Copy link
Contributor

Choose a reason for hiding this comment

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

@renatgalimov, I created a separate PR (#609) that centralizes error-handling (using panic-overlay under the hood). We can get it merged first. That way, this comment gets resolved.

- Update package.json scripts to use edge-apps-scripts
- Remove local eslint.config.ts, tailwind.config.js, and tsconfig.json
- Clean up devDependencies (remove eslint, tailwindcss, etc.)
- Update build output paths from static/ to dist/
- Import input.css in main.ts for Vite build
- Align with qr-code and menu-board patterns
- Extract helper functions in parser.ts and main.ts
- Add eslint-disable comments for test files
- Use getHardware() function directly instead of isAnywhereScreen()
- Remove isAnywhereScreen() function from edge-apps-library
- Update fetcher to check hardware === Hardware.Anywhere
- Update test mocks to use getHardware and Hardware enum
- Remove isAnywhereScreen tests from metadata.test.ts
- Move markup to HTML template elements in index.html
- Replace createElement calls with template cloning in main.ts
- Separate HTML structure from JavaScript logic
- Maintain all existing functionality and styling
…ching

- Remove offline_mode from screenly.yml and screenly_qc.yml
- Remove offlineMode from FetcherConfig interface
- Remove offline mode check in fetchLiveData()
- Always use localStorage cache as fallback when network fails
- System is now always available offline by default
- Remove offline mode tests
- Remove mute_sound setting from manifest files
- Remove audio template and related event listeners from HTML
- Remove createAudioPlayer function and playAudio parameter from main.ts
- Update documentation to reflect audio removal
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 27 out of 29 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +4 to +23
function parseResource(res: Record<string, unknown>) {
return {
resourceDesc: res.resourceDesc,
mimeType: res.mimeType || res['mimeType'],
size: res.size,
uri: res.uri,
derefUri: res.derefUri,
digest: res.digest,
url: res.uri || res.resourceDesc || '',
}
}

function parseArea(area: Record<string, unknown>) {
return {
areaDesc: area.areaDesc || '',
polygon: area.polygon,
circle: area.circle,
geocode: area.geocode,
altitude: area.altitude,
ceiling: area.ceiling,
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The parser accesses properties on 'res' and 'area' using type assertions without validation. These dynamic properties from XML parsing could be undefined or have unexpected types, potentially causing runtime errors. Consider adding runtime type checking or default values.

Suggested change
function parseResource(res: Record<string, unknown>) {
return {
resourceDesc: res.resourceDesc,
mimeType: res.mimeType || res['mimeType'],
size: res.size,
uri: res.uri,
derefUri: res.derefUri,
digest: res.digest,
url: res.uri || res.resourceDesc || '',
}
}
function parseArea(area: Record<string, unknown>) {
return {
areaDesc: area.areaDesc || '',
polygon: area.polygon,
circle: area.circle,
geocode: area.geocode,
altitude: area.altitude,
ceiling: area.ceiling,
function getStringOrUndefined(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
function getNumberOrUndefined(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined
}
function parseResource(res: Record<string, unknown>) {
const resourceDesc = getStringOrUndefined(res.resourceDesc)
const mimeType =
getStringOrUndefined((res as Record<string, unknown>).mimeType) ??
getStringOrUndefined((res as Record<string, unknown>)['mimeType'])
const size = getNumberOrUndefined(res.size)
const uri = getStringOrUndefined(res.uri)
const derefUri = getStringOrUndefined(res.derefUri)
const digest = getStringOrUndefined(res.digest)
return {
resourceDesc,
mimeType,
size,
uri,
derefUri,
digest,
url: uri ?? resourceDesc ?? '',
}
}
function parseArea(area: Record<string, unknown>) {
const areaDesc = getStringOrUndefined(area.areaDesc) ?? ''
const polygon = getStringOrUndefined(area.polygon) ?? area.polygon
const circle = getStringOrUndefined(area.circle) ?? area.circle
const geocode = area.geocode
const altitude = getNumberOrUndefined(area.altitude) ?? area.altitude
const ceiling = getNumberOrUndefined(area.ceiling) ?? area.ceiling
return {
areaDesc,
polygon,
circle,
geocode,
altitude,
ceiling,

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +42
export interface CAPInfo {
language: string
category?: string | string[]
event?: string
responseType?: string | string[]
urgency?: string
severity?: string
certainty?: string
audience?: string
effective?: string
onset?: string
expires?: string
senderName?: string
headline?: string
description?: string
instruction?: string
web?: string
contact?: string
parameter?: unknown
eventCode?: unknown
resources: CAPResource[]
areas: CAPArea[]
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The type definition has inconsistent property naming. The 'resource' and 'area' properties in CAPInfo should be 'resources' and 'areas' (plural) to match the parser implementation and usage throughout the codebase.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +66
max_alerts:
type: string
default_value: Infinity
title: Maximum Alerts
optional: true
help_text:
properties:
help_text: Maximum number of alerts to display simultaneously.
type: number
schema_version: 1
mode:
type: string
default_value: production
title: Mode
optional: true
help_text:
properties:
help_text: Select the operation mode for the app.
options:
- label: Production
value: production
- label: Demo
value: demo
- label: Test
value: test
type: select
schema_version: 1
refresh_interval:
type: string
default_value: '5'
title: Refresh Interval (minutes)
optional: true
help_text:
properties:
help_text: Time in minutes between feed updates.
type: number
schema_version: 1
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The settings type declarations are inconsistent. In screenly.yml and screenly_qc.yml, 'max_alerts' and 'refresh_interval' are declared as 'type: string' but their help_text specifies 'type: number'. This mismatch will cause confusion and potential type errors.

Copilot uses AI. Check for mistakes.
- Consolidate mode options into single Mode setting
- Remove references to non-existent separate settings
- Update defaults to match manifest
- Clarify demo mode activation
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