Relocate JavaScript coverage export under web (#266)

This commit is contained in:
l5y
2025-10-07 15:49:40 +02:00
committed by GitHub
parent 2acfca20d9
commit 4f5aec45b3
10 changed files with 300 additions and 62 deletions
+42
View File
@@ -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
+4
View File
@@ -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 <https://github.com/l5yth/potato-mesh/pull/237>
+1 -1
View File
@@ -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)
+12
View File
@@ -0,0 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.0"
}
}
}
+9
View File
@@ -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"
}
}
@@ -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);
});
@@ -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;
+2 -61
View File
@@ -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<string, *>} 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
+61
View File
@@ -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<string, *>} 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;
}
+45
View File
@@ -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();