From 4f5aec45b3ef98fdb77f37bff791098be39df7a1 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:49:40 +0200 Subject: [PATCH] Relocate JavaScript coverage export under web (#266) --- .github/workflows/javascript.yml | 42 +++++++++ CHANGELOG.md | 4 + web/app.rb | 2 +- web/package-lock.json | 12 +++ web/package.json | 9 ++ .../assets/js/app/__tests__/config.test.js | 85 +++++++++++++++++++ .../assets/js/app/__tests__/document-stub.js | 39 +++++++++ web/public/assets/js/app/index.js | 63 +------------- web/public/assets/js/app/settings.js | 61 +++++++++++++ web/scripts/export-coverage.js | 45 ++++++++++ 10 files changed, 300 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/javascript.yml create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/assets/js/app/__tests__/config.test.js create mode 100644 web/public/assets/js/app/__tests__/document-stub.js create mode 100644 web/public/assets/js/app/settings.js create mode 100644 web/scripts/export-coverage.js diff --git a/.github/workflows/javascript.yml b/.github/workflows/javascript.yml new file mode 100644 index 0000000..1c22d77 --- /dev/null +++ b/.github/workflows/javascript.yml @@ -0,0 +1,42 @@ +name: JavaScript + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install dependencies + run: npm install + working-directory: web + - name: Run JavaScript tests + run: npm test + working-directory: web + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: web/reports/javascript-coverage.json + flags: javascript + name: javascript + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Upload test results to Codecov + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: web/reports/javascript-junit.xml + flags: javascript diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6d5b8..84097cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## v0.5.0 + +* Add JavaScript configuration tests and coverage workflow + ## v0.4.0 * Reformat neighbor overlay layout by @l5yth in diff --git a/web/app.rb b/web/app.rb index 44e4307..8c6222c 100644 --- a/web/app.rb +++ b/web/app.rb @@ -48,7 +48,7 @@ MAX_JSON_BODY_BYTES = begin DEFAULT_MAX_JSON_BODY_BYTES end # Fallback version string used when Git metadata is unavailable. -VERSION_FALLBACK = "v0.4.0" +VERSION_FALLBACK = "v0.5.0" DEFAULT_REFRESH_INTERVAL_SECONDS = 60 REFRESH_INTERVAL_SECONDS = begin raw = ENV.fetch("REFRESH_INTERVAL_SECONDS", DEFAULT_REFRESH_INTERVAL_SECONDS.to_s) diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ce7ebe5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "potato-mesh", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "potato-mesh", + "version": "0.5.0" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3a40b30 --- /dev/null +++ b/web/package.json @@ -0,0 +1,9 @@ +{ + "name": "potato-mesh", + "version": "0.5.0", + "type": "module", + "private": true, + "scripts": { + "test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js" + } +} diff --git a/web/public/assets/js/app/__tests__/config.test.js b/web/public/assets/js/app/__tests__/config.test.js new file mode 100644 index 0000000..e5a580a --- /dev/null +++ b/web/public/assets/js/app/__tests__/config.test.js @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { documentStub, resetDocumentStub } from './document-stub.js'; + +import { readAppConfig } from '../config.js'; +import { DEFAULT_CONFIG, mergeConfig } from '../settings.js'; + +test('readAppConfig returns an empty object when the configuration element is missing', () => { + resetDocumentStub(); + assert.deepEqual(readAppConfig(), {}); +}); + +test('readAppConfig returns an empty object when the attribute is empty', () => { + resetDocumentStub(); + documentStub.setConfigElement({ getAttribute: () => '' }); + assert.deepEqual(readAppConfig(), {}); +}); + +test('readAppConfig parses configuration JSON from the DOM attribute', () => { + resetDocumentStub(); + const data = { refreshMs: 5000, chatEnabled: false }; + documentStub.setConfigElement({ + getAttribute: name => (name === 'data-app-config' ? JSON.stringify(data) : null) + }); + assert.deepEqual(readAppConfig(), data); +}); + +test('readAppConfig returns an empty object and logs on parse failure', () => { + resetDocumentStub(); + let called = false; + const originalError = console.error; + console.error = () => { + called = true; + }; + documentStub.setConfigElement({ + getAttribute: name => (name === 'data-app-config' ? 'not-json' : null) + }); + + assert.deepEqual(readAppConfig(), {}); + assert.equal(called, true); + console.error = originalError; +}); + +test('mergeConfig applies default values when fields are missing', () => { + const result = mergeConfig({}); + assert.deepEqual(result, { + ...DEFAULT_CONFIG, + mapCenter: { ...DEFAULT_CONFIG.mapCenter }, + tileFilters: { ...DEFAULT_CONFIG.tileFilters } + }); +}); + +test('mergeConfig coerces numeric values and nested objects', () => { + const result = mergeConfig({ + refreshIntervalSeconds: '30', + refreshMs: '45000', + mapCenter: { lat: '10.5', lon: '20.1' }, + tileFilters: { dark: 'contrast(2)' }, + chatEnabled: 0, + defaultChannel: '#Custom', + defaultFrequency: '915MHz', + maxNodeDistanceKm: '55.5' + }); + + assert.equal(result.refreshIntervalSeconds, 30); + assert.equal(result.refreshMs, 45000); + assert.deepEqual(result.mapCenter, { lat: 10.5, lon: 20.1 }); + assert.deepEqual(result.tileFilters, { light: DEFAULT_CONFIG.tileFilters.light, dark: 'contrast(2)' }); + assert.equal(result.chatEnabled, false); + assert.equal(result.defaultChannel, '#Custom'); + assert.equal(result.defaultFrequency, '915MHz'); + assert.equal(result.maxNodeDistanceKm, 55.5); +}); + +test('mergeConfig falls back to defaults for invalid numeric values', () => { + const result = mergeConfig({ + refreshIntervalSeconds: 'NaN', + refreshMs: 'NaN', + maxNodeDistanceKm: 'oops' + }); + + assert.equal(result.refreshIntervalSeconds, DEFAULT_CONFIG.refreshIntervalSeconds); + assert.equal(result.refreshMs, DEFAULT_CONFIG.refreshMs); + assert.equal(result.maxNodeDistanceKm, DEFAULT_CONFIG.maxNodeDistanceKm); +}); diff --git a/web/public/assets/js/app/__tests__/document-stub.js b/web/public/assets/js/app/__tests__/document-stub.js new file mode 100644 index 0000000..414ee89 --- /dev/null +++ b/web/public/assets/js/app/__tests__/document-stub.js @@ -0,0 +1,39 @@ +class DocumentStub { + constructor() { + this.reset(); + } + + reset() { + this.configElement = null; + this.listeners = new Map(); + } + + setConfigElement(element) { + this.configElement = element; + } + + querySelector(selector) { + if (selector === '[data-app-config]') { + return this.configElement; + } + return null; + } + + addEventListener(event, handler) { + this.listeners.set(event, handler); + } + + dispatchEvent(event) { + const handler = this.listeners.get(event); + if (handler) { + handler(); + } + } +} + +export const documentStub = new DocumentStub(); +export function resetDocumentStub() { + documentStub.reset(); +} + +globalThis.document = documentStub; diff --git a/web/public/assets/js/app/index.js b/web/public/assets/js/app/index.js index ae91554..a6a2b30 100644 --- a/web/public/assets/js/app/index.js +++ b/web/public/assets/js/app/index.js @@ -16,68 +16,9 @@ import { readAppConfig } from './config.js'; import { initializeApp } from './main.js'; +import { DEFAULT_CONFIG, mergeConfig } from './settings.js'; -/** - * Default configuration values applied when the server omits a field. - * - * @type {{ - * refreshMs: number, - * refreshIntervalSeconds: number, - * chatEnabled: boolean, - * defaultChannel: string, - * defaultFrequency: string, - * mapCenter: { lat: number, lon: number }, - * maxNodeDistanceKm: number, - * tileFilters: { light: string, dark: string } - * }} - */ -const DEFAULT_CONFIG = { - refreshMs: 60_000, - refreshIntervalSeconds: 60, - chatEnabled: true, - defaultChannel: '#MediumFast', - defaultFrequency: '868MHz', - mapCenter: { lat: 52.502889, lon: 13.404194 }, - maxNodeDistanceKm: 137, - tileFilters: { - light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)', - dark: 'grayscale(1) invert(1) brightness(0.9) contrast(1.08)' - } -}; - -/** - * Merge raw configuration data from the DOM with the defaults. - * - * @param {Object} raw Partial configuration read from ``readAppConfig``. - * @returns {typeof DEFAULT_CONFIG} Fully populated configuration object. - */ -function mergeConfig(raw) { - const config = { ...DEFAULT_CONFIG, ...(raw || {}) }; - config.mapCenter = { - lat: Number(raw?.mapCenter?.lat ?? DEFAULT_CONFIG.mapCenter.lat), - lon: Number(raw?.mapCenter?.lon ?? DEFAULT_CONFIG.mapCenter.lon) - }; - config.tileFilters = { - light: raw?.tileFilters?.light || DEFAULT_CONFIG.tileFilters.light, - dark: raw?.tileFilters?.dark || DEFAULT_CONFIG.tileFilters.dark - }; - const refreshIntervalSeconds = Number( - raw?.refreshIntervalSeconds ?? DEFAULT_CONFIG.refreshIntervalSeconds - ); - config.refreshIntervalSeconds = Number.isFinite(refreshIntervalSeconds) - ? refreshIntervalSeconds - : DEFAULT_CONFIG.refreshIntervalSeconds; - const refreshMs = Number(raw?.refreshMs ?? config.refreshIntervalSeconds * 1000); - config.refreshMs = Number.isFinite(refreshMs) ? refreshMs : DEFAULT_CONFIG.refreshMs; - config.chatEnabled = Boolean(raw?.chatEnabled ?? DEFAULT_CONFIG.chatEnabled); - config.defaultChannel = raw?.defaultChannel || DEFAULT_CONFIG.defaultChannel; - config.defaultFrequency = raw?.defaultFrequency || DEFAULT_CONFIG.defaultFrequency; - const maxDistance = Number(raw?.maxNodeDistanceKm ?? DEFAULT_CONFIG.maxNodeDistanceKm); - config.maxNodeDistanceKm = Number.isFinite(maxDistance) - ? maxDistance - : DEFAULT_CONFIG.maxNodeDistanceKm; - return config; -} +export { DEFAULT_CONFIG, mergeConfig } from './settings.js'; /** * Bootstraps the application once the DOM is ready by reading configuration diff --git a/web/public/assets/js/app/settings.js b/web/public/assets/js/app/settings.js new file mode 100644 index 0000000..c276d44 --- /dev/null +++ b/web/public/assets/js/app/settings.js @@ -0,0 +1,61 @@ +/** + * Default configuration values applied when the server omits a field. + * + * @type {{ + * refreshMs: number, + * refreshIntervalSeconds: number, + * chatEnabled: boolean, + * defaultChannel: string, + * defaultFrequency: string, + * mapCenter: { lat: number, lon: number }, + * maxNodeDistanceKm: number, + * tileFilters: { light: string, dark: string } + * }} + */ +export const DEFAULT_CONFIG = { + refreshMs: 60_000, + refreshIntervalSeconds: 60, + chatEnabled: true, + defaultChannel: '#MediumFast', + defaultFrequency: '868MHz', + mapCenter: { lat: 52.502889, lon: 13.404194 }, + maxNodeDistanceKm: 137, + tileFilters: { + light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)', + dark: 'grayscale(1) invert(1) brightness(0.9) contrast(1.08)' + } +}; + +/** + * Merge raw configuration data from the DOM with the defaults. + * + * @param {Object} raw Partial configuration read from ``readAppConfig``. + * @returns {typeof DEFAULT_CONFIG} Fully populated configuration object. + */ +export function mergeConfig(raw) { + const config = { ...DEFAULT_CONFIG, ...(raw || {}) }; + config.mapCenter = { + lat: Number(raw?.mapCenter?.lat ?? DEFAULT_CONFIG.mapCenter.lat), + lon: Number(raw?.mapCenter?.lon ?? DEFAULT_CONFIG.mapCenter.lon) + }; + config.tileFilters = { + light: raw?.tileFilters?.light || DEFAULT_CONFIG.tileFilters.light, + dark: raw?.tileFilters?.dark || DEFAULT_CONFIG.tileFilters.dark + }; + const refreshIntervalSeconds = Number( + raw?.refreshIntervalSeconds ?? DEFAULT_CONFIG.refreshIntervalSeconds + ); + config.refreshIntervalSeconds = Number.isFinite(refreshIntervalSeconds) + ? refreshIntervalSeconds + : DEFAULT_CONFIG.refreshIntervalSeconds; + const refreshMs = Number(raw?.refreshMs ?? config.refreshIntervalSeconds * 1000); + config.refreshMs = Number.isFinite(refreshMs) ? refreshMs : DEFAULT_CONFIG.refreshMs; + config.chatEnabled = Boolean(raw?.chatEnabled ?? DEFAULT_CONFIG.chatEnabled); + config.defaultChannel = raw?.defaultChannel || DEFAULT_CONFIG.defaultChannel; + config.defaultFrequency = raw?.defaultFrequency || DEFAULT_CONFIG.defaultFrequency; + const maxDistance = Number(raw?.maxNodeDistanceKm ?? DEFAULT_CONFIG.maxNodeDistanceKm); + config.maxNodeDistanceKm = Number.isFinite(maxDistance) + ? maxDistance + : DEFAULT_CONFIG.maxNodeDistanceKm; + return config; +} diff --git a/web/scripts/export-coverage.js b/web/scripts/export-coverage.js new file mode 100644 index 0000000..34b1404 --- /dev/null +++ b/web/scripts/export-coverage.js @@ -0,0 +1,45 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +const coverageDir = 'coverage'; +const reportsDir = 'reports'; +const outputPath = path.join(reportsDir, 'javascript-coverage.json'); + +async function ensureReportsDir() { + try { + await fs.mkdir(reportsDir, { recursive: true }); + } catch (error) { + console.error('Failed to ensure reports directory', error); + process.exit(1); + } +} + +async function copyLatestCoverage() { + let entries; + try { + entries = await fs.readdir(coverageDir); + } catch (error) { + if (error.code === 'ENOENT') { + console.warn('Coverage directory not found; skipping export.'); + return; + } + throw error; + } + + const coverageFiles = entries.filter(name => name.endsWith('.json')); + if (!coverageFiles.length) { + console.warn('No coverage files generated; skipping export.'); + return; + } + + // Sort to pick the most recent entry deterministically. + coverageFiles.sort(); + const latest = coverageFiles[coverageFiles.length - 1]; + const source = path.join(coverageDir, latest); + + await fs.copyFile(source, outputPath); + console.log(`Copied coverage report to ${outputPath}`); +} + +await ensureReportsDir(); +await copyLatestCoverage();