mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-07 09:22:53 +02:00
Relocate JavaScript coverage export under web (#266)
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Generated
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "potato-mesh",
|
||||
"version": "0.5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user