feat(dashboard): version indicator + GHCR update check (#165)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled

* feat(dashboard): version indicator + GHCR update check with admin panel

- Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter)
- Extend /api/version with latest_version and update_available
- Add POST /api/admin/check-update (force GHCR poll)
- Add POST /api/admin/trigger-update (Docker compose or manual instructions)
- Sidebar footer: version label + amber badge when update available
- Admin > System: version comparison card with Check/Update buttons
- 14 new tests (10 unit + 4 integration), 68/68 passing

Closes #163

* fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout

Review follow-up on #165 (the two blockers):

- trigger-update runs `docker compose up -d` on the HOST via docker.sock
  (root-equivalent) but was behind requireAdmin, i.e. reachable by any
  workspace-level admin. On a multi-tenant host that's a customer, not the infra
  operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates
  it further). check-update stays requireAdmin — it's a read-only GHCR poll.

- ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default
  timeout, so a hung GHCR connection never settled — leaving `inFlight` set
  forever (the finally never ran), which wedged the background poller AND hung
  any awaited checkNow (/api/admin/check-update). Add a 10s AbortController
  timeout on both requests so the try/catch/finally always fire.

All 405 server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ScreenTinker <hello@screentinker.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian Mendoza 2026-07-10 23:40:06 -04:00 committed by GitHub
parent 1ebdb1f7a9
commit 34f1cb9e7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 484 additions and 9 deletions

View file

@ -253,6 +253,25 @@ body {
color: var(--text-muted);
}
.version-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
}
.version-badge {
background: var(--warning);
color: #000;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 10px;
line-height: 1.4;
}
.status-dot {
width: 8px;
height: 8px;

View file

@ -152,6 +152,10 @@
<span class="status-dot offline"></span>
<span>Disconnected</span>
</div>
<div class="version-status" id="versionStatus">
<span id="versionLabel"></span>
<span class="version-badge" id="versionBadge" hidden>Update</span>
</div>
</div>
</nav>

View file

@ -567,13 +567,19 @@ window.addEventListener('keydown', (e) => {
// Auto-reload on frontend update (no more hard refresh needed)
let knownHash = null;
export function updateVersionIndicator({ version, latest_version, update_available }) {
const label = document.getElementById('versionLabel');
const badge = document.getElementById('versionBadge');
if (label) label.textContent = version ? 'v' + version : '';
if (badge) badge.hidden = !update_available;
}
setInterval(async () => {
try {
const res = await fetch('/api/version');
const { hash } = await res.json();
if (knownHash === null) { knownHash = hash; return; }
if (hash !== knownHash) {
knownHash = hash;
const data = await res.json();
if (knownHash === null) { knownHash = data.hash; }
else if (data.hash !== knownHash) {
knownHash = data.hash;
const toast = document.getElementById('toastContainer');
if (toast) {
const notice = document.createElement('div');
@ -582,6 +588,7 @@ setInterval(async () => {
toast.appendChild(notice);
}
}
updateVersionIndicator(data);
} catch {}
}, 15000);

View file

@ -406,16 +406,128 @@ async function loadSystem() {
try {
const version = await fetch('/api/version').then(r => r.json());
const token = localStorage.getItem('token');
const versionComparison = version.latest_version
? `<div class="info-card">
<div class="info-card-label">${t('admin.latest_version') || 'Latest Version'}</div>
<div class="info-card-value small">${esc(version.latest_version)}</div>
</div>
<div class="info-card">
<div class="info-card-label">${t('admin.status') || 'Status'}</div>
<div class="info-card-value small" style="color:${version.update_available ? 'var(--warning)' : 'var(--success)'}">${version.update_available ? (t('admin.update_available') || 'Update Available') : (t('admin.up_to_date') || 'Up to Date')}</div>
</div>`
: `<div class="info-card">
<div class="info-card-label">${t('admin.latest_version') || 'Latest Version'}</div>
<div class="info-card-value small" style="color:var(--text-muted)">${t('admin.checking') || 'Checking...'}</div>
</div>`;
el.innerHTML = `
<div class="info-grid">
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${version.version}</div></div>
<div class="info-card"><div class="info-card-label">${t('admin.frontend_hash')}</div><div class="info-card-value small">${version.hash}</div></div>
<div class="info-card"><div class="info-card-label">${t('admin.version')}</div><div class="info-card-value small">${esc(version.version)}</div></div>
${versionComparison}
</div>
<div style="display:flex;gap:8px;margin-top:16px">
<div style="display:flex;gap:8px;margin-top:16px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm" id="checkUpdateBtn">${t('admin.check_now') || 'Check Now'}</button>
<button class="btn btn-primary btn-sm" id="triggerUpdateBtn"${!version.update_available ? ' style="display:none"' : ''}>${t('admin.update_now') || 'Update Now'}</button>
<a href="/api/status/backup?token=${token}" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.download_db_backup')}</a>
<a href="/api/status" target="_blank" class="btn btn-secondary btn-sm" style="text-decoration:none">${t('admin.server_status')}</a>
</div>
<div id="updateResult" style="margin-top:12px"></div>
`;
// Check Now button
document.getElementById('checkUpdateBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('checkUpdateBtn');
btn.disabled = true;
btn.textContent = t('admin.checking') || 'Checking...';
try {
const res = await fetch('/api/admin/check-update', { method: 'POST', headers: headers() });
const data = await res.json();
const updBtn = document.getElementById('triggerUpdateBtn');
if (data.update_available && updBtn) {
updBtn.style.display = '';
}
loadSystem(); // refresh the whole card
} catch (err) {
showToast(err.message, 'error');
btn.disabled = false;
btn.textContent = t('admin.check_now') || 'Check Now';
}
});
// Update Now button
document.getElementById('triggerUpdateBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('triggerUpdateBtn');
const resultEl = document.getElementById('updateResult');
btn.disabled = true;
btn.textContent = t('admin.updating') || 'Updating...';
try {
const res = await fetch('/api/admin/trigger-update', { method: 'POST', headers: headers() });
const data = await res.json();
if (data.docker_enabled) {
// Docker executed — show output with Copy button
resultEl.innerHTML = `
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-card)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong style="font-size:13px">${data.success ? (t('admin.update_success') || 'Update Successful') : (t('admin.update_failed') || 'Update Failed')}</strong>
<button class="btn btn-secondary btn-sm" id="copyOutputBtn">${t('admin.copy') || 'Copy'}</button>
</div>
<pre style="max-height:300px;overflow:auto;font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.output || '')}</pre>
</div>`;
document.getElementById('copyOutputBtn')?.addEventListener('click', () => {
const pre = resultEl.querySelector('pre');
const text = pre ? pre.textContent : '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied') || 'Copied!', 'success'));
} else {
// Fallback for older browsers
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast(t('admin.copied') || 'Copied!', 'success');
}
});
} else if (data.instructions) {
// Docker disabled — show manual instructions with Copy button
resultEl.innerHTML = `
<div style="margin-top:12px;border:1px solid var(--border);border-radius:var(--radius);padding:12px;background:var(--bg-secondary)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<strong style="font-size:13px">${t('admin.manual_update') || 'Manual Update Required'}</strong>
<button class="btn btn-secondary btn-sm" id="copyCmdBtn">${t('admin.copy_command') || 'Copy'}</button>
</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">${t('admin.manual_update_desc') || 'Run this command on the server:'}</p>
<pre style="font-size:11px;margin:0;background:var(--bg-primary);padding:8px;border-radius:4px;white-space:pre-wrap;word-break:break-all">${esc(data.instructions)}</pre>
</div>`;
document.getElementById('copyCmdBtn')?.addEventListener('click', () => {
const pre = resultEl.querySelector('pre');
const text = pre ? pre.textContent : '';
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied') || 'Copied!', 'success'));
} else {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast(t('admin.copied') || 'Copied!', 'success');
}
});
}
} catch (err) {
showToast(err.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = t('admin.update_now') || 'Update Now';
}
});
} catch (err) { el.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`; }
}

View file

@ -304,6 +304,12 @@ module.exports = {
contentAckMaxPerWindow: parseInt(process.env.CONTENT_ACK_MAX_PER_WINDOW) || 20,
contentAckRateWindowMs: parseInt(process.env.CONTENT_ACK_RATE_WINDOW_MS) || 10000,
// Version update indicator — polls GHCR for the latest Docker image tag via
// anonymous token flow. All optional with safe defaults.
dockerUpdateEnabled: process.env.DOCKER_UPDATE_ENABLED === 'true',
ghcrCheckIntervalHours: parseInt(process.env.GHCR_CHECK_INTERVAL_HOURS) || 36,
composeFilePath: process.env.COMPOSE_FILE_PATH || '/opt/screentinker/docker-compose.yml',
// #143 fingerprint-reclaim liveness. A reinstalled app (same fingerprint, no
// device_id, has pairing_code) may reclaim its old device's identity once that
// device is gone by RUNTIME signals: no live socket AND last heartbeat older than

171
server/lib/ghcr-check.js Normal file
View file

@ -0,0 +1,171 @@
'use strict';
// In-memory cache for the latest version discovered from GHCR. Restart = fresh poll.
// 36h interval means ~1 request per deploy cycle — no persistence needed.
let latestVersion = null; // string | null — highest semver tag found
let checkedAt = null; // number (epoch ms) | null — last poll timestamp
let inFlight = null; // Promise | null — dedup concurrent polls
// Extract semver x.y.z tags from a tag list array, ignoring pre-release suffixes
// and non-semver labels. Returns tags sorted descending (latest first) by numeric
// component comparison so [0] is the highest version.
function extractSemverTags(tags) {
if (!Array.isArray(tags) || tags.length === 0) return [];
// Must match the ENTIRE string — pre-release suffixes (-beta, -rc1) are excluded
const semverRegex = /^(\d+)\.(\d+)\.(\d+)$/;
const parsed = [];
for (const tag of tags) {
const m = tag.match(semverRegex);
if (!m) continue;
parsed.push({ tag, major: +m[1], minor: +m[2], patch: +m[3] });
}
parsed.sort((a, b) => {
if (a.major !== b.major) return b.major - a.major;
if (a.minor !== b.minor) return b.minor - a.minor;
return b.patch - a.patch;
});
return parsed.map(p => p.tag);
}
// Compare two semver strings element-wise. Returns:
// negative → a < b
// 0 → a == b
// positive → a > b
// NaN → one or both inputs are not semver
function compareVersions(a, b) {
// Match the ENTIRE string — pre-release suffixes rejected
const ra = a.match(/^(\d+)\.(\d+)\.(\d+)$/);
const rb = b.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!ra || !rb) return NaN;
for (let i = 1; i <= 3; i++) {
const diff = +ra[i] - +rb[i];
if (diff !== 0) return diff;
}
return 0;
}
// Synchronous cache reader. Returns null before the first poll completes.
function getLatestVersion() {
return latestVersion;
}
// Hard per-request cap. Node's global fetch has NO default timeout, so a hung GHCR
// connection would otherwise never settle — leaving `inFlight` set forever (the finally
// never runs) and wedging BOTH the background poller and any awaited checkNow (e.g.
// /api/admin/check-update). AbortController makes a stalled fetch reject so the outer
// try/catch/finally always fire and the cache keeps serving.
const FETCH_TIMEOUT_MS = 10000;
async function fetchWithTimeout(url, opts) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...(opts || {}), signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}
// Force a fresh GHCR poll (bypasses cache), fetch tags, extract the highest
// semver, and update the cache. Returns { latest, update_available }.
async function checkNow(currentVersion) {
// De-duplicate: if a poll is already in flight, wait for it instead of
// starting a second concurrent fetch.
if (inFlight) return inFlight;
inFlight = (async () => {
try {
// Step 1: get anonymous OAuth token for GHCR public repo access
const tokenRes = await fetchWithTimeout(
'https://ghcr.io/token?scope=repository:screentinker/screentinker:pull'
);
if (!tokenRes.ok) throw new Error(`GHCR token endpoint returned ${tokenRes.status}`);
const { token } = await tokenRes.json();
if (!token) throw new Error('GHCR token response missing token field');
// Step 2: list tags with Bearer auth
const tagsUrl = 'https://ghcr.io/v2/screentinker/screentinker/tags/list';
const tagsRes = await fetchWithTimeout(tagsUrl, {
headers: { Authorization: `Bearer ${token}` },
});
// Some registries return 401/404 if no tags exist yet — not an error,
// just means no releases published.
if (tagsRes.status === 401 || tagsRes.status === 404) {
latestVersion = null;
checkedAt = Date.now();
return { latest: null, update_available: false };
}
if (!tagsRes.ok) throw new Error(`GHCR tags endpoint returned ${tagsRes.status}`);
const body = await tagsRes.json();
// Handle both { tags: [...] } and { name, tags: [...] } response shapes
const tags = body.tags || [];
if (!Array.isArray(tags) || tags.length === 0) {
latestVersion = null;
checkedAt = Date.now();
return { latest: null, update_available: false };
}
// Extract and sort semver tags
const semverTags = extractSemverTags(tags);
if (semverTags.length === 0) {
latestVersion = null;
checkedAt = Date.now();
return { latest: null, update_available: false };
}
// The highest version is the first element (sorted descending)
const latest = semverTags[0];
latestVersion = latest;
checkedAt = Date.now();
const updateAvailable = currentVersion
? compareVersions(latest, currentVersion) > 0
: false;
return { latest, update_available: updateAvailable };
} catch (err) {
// Network errors, DNS failures, etc. — silent, cache stays as-is.
// The next poll will retry; the existing cache (if any) still serves.
console.error('[ghcr-check] poll failed:', err.message);
return { latest: latestVersion, update_available: false };
} finally {
// Clear in-flight guard so next poll can run
inFlight = null;
}
})();
return inFlight;
}
// Start background polling at the given interval (in hours). First poll fires
// after a 30s initial delay to let the server stabilize.
function startPolling(intervalHours, currentVersion) {
const intervalMs = intervalHours * 60 * 60 * 1000;
// Initial poll after 30s
setTimeout(() => {
checkNow(currentVersion).catch(() => {});
}, 30000);
// Periodic poll
setInterval(() => {
checkNow(currentVersion).catch(() => {});
}, intervalMs);
}
module.exports = {
extractSemverTags,
compareVersions,
getLatestVersion,
checkNow,
startPolling,
};

View file

@ -4,7 +4,7 @@ const bcrypt = require('bcryptjs');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const { canAdminWorkspace } = require('../lib/permissions');
const { requirePlatformAdmin } = require('../middleware/auth');
const { requirePlatformAdmin, requireAdmin } = require('../middleware/auth');
const { logActivity, getClientIp } = require('../services/activity');
const { deleteWorkspaceCascade, deleteOrgCascade } = require('../lib/user-deletion');
const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require('../lib/branding');
@ -376,4 +376,52 @@ router.put('/status-debug', requirePlatformAdmin, (req, res) => {
res.json({ enabled });
});
// ===================== Version update indicator =====================
// check-update = requireAdmin — a read-only GHCR poll, operational.
// trigger-update = requirePlatformAdmin — it runs `docker compose up -d` on the
// HOST via docker.sock (root-equivalent), so it's restricted to platform-owner
// level; DOCKER_UPDATE_ENABLED gates it further (off by default).
const ghcrCheck = require('../lib/ghcr-check');
const VERSION = require('../version');
// POST /api/admin/check-update — force a fresh GHCR poll (bypasses cache)
// and return version comparison.
router.post('/check-update', requireAdmin, async (req, res) => {
try {
const result = await ghcrCheck.checkNow(VERSION);
res.json({
current: VERSION,
latest: result.latest,
update_available: result.update_available,
});
} catch (err) {
res.status(502).json({ error: 'GHCR poll failed', detail: err.message });
}
});
// POST /api/admin/trigger-update — run docker compose pull && up -d,
// or return manual instructions when docker is disabled.
router.post('/trigger-update', requirePlatformAdmin, async (req, res) => {
const { exec } = require('child_process');
const composeFile = require('../config').composeFilePath;
const cmd = `docker compose -f ${composeFile} pull && docker compose -f ${composeFile} up -d`;
if (!require('../config').dockerUpdateEnabled) {
return res.json({
docker_enabled: false,
instructions: cmd,
});
}
exec(cmd, { timeout: 60000 }, (err, stdout, stderr) => {
const output = (stdout || '') + (stderr || '');
if (err) {
return res.json({ success: false, output, docker_enabled: true, error: err.message });
}
logActivity(req.user.id, 'admin_trigger_update', `docker compose up -d`, null, getClientIp(req), null);
res.json({ success: true, output, docker_enabled: true });
});
});
module.exports = router;

View file

@ -7,6 +7,7 @@ const path = require('path');
const fs = require('fs');
const config = require('./config');
const VERSION = require('./version');
const ghcrCheck = require('./lib/ghcr-check');
// #114: last-resort crash safety net. better-sqlite3 is SYNCHRONOUS, so a constraint
// violation (e.g. a FK write) inside a socket.io handler with no local try/catch
@ -586,7 +587,9 @@ updateFrontendHash();
// Recheck every 30 seconds
setInterval(updateFrontendHash, 30000);
app.get('/api/version', (req, res) => {
res.json({ hash: frontendHash, version: VERSION });
const latest = ghcrCheck.getLatestVersion();
const updateAvailable = latest ? ghcrCheck.compareVersions(latest, VERSION) > 0 : false;
res.json({ hash: frontendHash, version: VERSION, latest_version: latest, update_available: updateAvailable });
});
// Public status page
@ -749,6 +752,10 @@ startAgencyDigest();
const { startWalCheckpointer, stopWalCheckpointer } = require('./db/wal-checkpointer');
startWalCheckpointer(require('./db/database').db, config.dbPath);
// Version update indicator: poll GHCR for latest image tag, cache in memory.
// First poll fires after 30s to let the server stabilize.
ghcrCheck.startPolling(config.ghcrCheckIntervalHours, VERSION);
// Graceful shutdown: stop the checkpointer worker (closes its own DB handle) + flush + close.
let _shuttingDown = false;
function gracefulShutdown(sig) {

View file

@ -400,3 +400,40 @@ test('pip clear: full token clears (POST /clear and DELETE), read token rejected
const del = await jfetch('/api/pip', { method: 'DELETE', ...auth(S.tok.full), body: JSON.stringify({ device_id: S.deviceId }) });
assert.equal(del.status, 200);
});
// ───────────────────────── TIER 6: VERSION & UPDATE INDICATOR ─────────────────────────
test('version: GET /api/version includes latest_version and update_available', async () => {
const res = await fetch(`${BASE}/api/version`);
assert.equal(res.status, 200);
const body = await res.json();
// Fields must exist (type presence, not specific value — server may or
// may not have polled GHCR yet).
assert.ok('latest_version' in body, '/api/version must include latest_version');
assert.ok('update_available' in body, '/api/version must include update_available');
assert.ok(typeof body.update_available === 'boolean', 'update_available must be boolean');
});
test('version: POST /api/admin/check-update returns version comparison (admin)', async () => {
// user1 is platform_admin — must be able to check-update
const res = await jfetch('/api/admin/check-update', post(S.jwt, {}));
assert.equal(res.status, 200);
assert.ok(res.body, 'check-update must return a body');
assert.ok('current' in res.body, 'must include current');
assert.ok('latest' in res.body, 'must include latest');
assert.ok('update_available' in res.body, 'must include update_available');
});
test('version: POST /api/admin/check-update rejects non-admin (403)', async () => {
// user2 is a regular user, not platform_admin — must be rejected
const res = await jfetch('/api/admin/check-update', post(S.jwt2, {}));
assert.equal(res.status, 403, 'non-admin must get 403 on check-update');
});
test('version: POST /api/admin/trigger-update (docker disabled) returns instructions', async () => {
const res = await jfetch('/api/admin/trigger-update', post(S.jwt, {}));
assert.equal(res.status, 200);
assert.ok(res.body, 'trigger-update must return a body');
assert.ok('instructions' in res.body || 'docker_enabled' in res.body,
'must return instructions or docker_enabled flag');
});

View file

@ -0,0 +1,64 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const ghcrCheck = require('../lib/ghcr-check');
// =========== extractSemverTags ===========
test('extractSemverTags: returns only valid x.y.z semver tags, pre-release suffixes excluded', () => {
const input = ['latest', '1.9.4', 'v1.9.4', '1.9.4-beta', '1.10.0', '2.0.0-rc1', '1.0.0-alpha'];
const result = ghcrCheck.extractSemverTags(input);
// Pre-release (-beta, -rc1, -alpha) and non-semver (latest, v1.9.4) excluded
assert.deepEqual(result, ['1.10.0', '1.9.4']);
});
test('extractSemverTags: sorts numeric components correctly (10 > 9)', () => {
const input = ['1.2.3', '1.10.0', '1.9.4', '1.2.10'];
const result = ghcrCheck.extractSemverTags(input);
assert.deepEqual(result, ['1.10.0', '1.9.4', '1.2.10', '1.2.3']);
});
test('extractSemverTags: empty input returns empty array', () => {
assert.deepEqual(ghcrCheck.extractSemverTags([]), []);
});
test('extractSemverTags: all non-semver tags returns empty array', () => {
assert.deepEqual(ghcrCheck.extractSemverTags(['latest', 'dev', 'beta']), []);
});
test('extractSemverTags: handles single valid tag', () => {
assert.deepEqual(ghcrCheck.extractSemverTags(['1.0.0']), ['1.0.0']);
});
// =========== compareVersions ===========
test('compareVersions: equal versions return 0', () => {
assert.equal(ghcrCheck.compareVersions('1.0.0', '1.0.0'), 0);
assert.equal(ghcrCheck.compareVersions('2.5.7', '2.5.7'), 0);
});
test('compareVersions: a < b returns negative', () => {
assert.ok(ghcrCheck.compareVersions('1.9.4', '1.10.0') < 0, 'minor bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '2.0.0') < 0, 'major bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.0.1') < 0, 'patch bump');
assert.ok(ghcrCheck.compareVersions('1.0.0', '1.1.0') < 0, 'minor bump 0->1');
});
test('compareVersions: a > b returns positive', () => {
assert.ok(ghcrCheck.compareVersions('2.0.0', '1.9.4') > 0, 'major larger');
assert.ok(ghcrCheck.compareVersions('1.10.0', '1.9.4') > 0, 'minor larger');
assert.ok(ghcrCheck.compareVersions('1.0.1', '1.0.0') > 0, 'patch larger');
});
test('compareVersions: non-semver input returns NaN', () => {
assert.ok(Number.isNaN(ghcrCheck.compareVersions('abc', '1.0.0')));
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0.0', 'latest')));
assert.ok(Number.isNaN(ghcrCheck.compareVersions('1.0', '1.0.0')));
});
// =========== getLatestVersion (sync cache read) ===========
test('getLatestVersion: returns null before any poll', () => {
assert.equal(ghcrCheck.getLatestVersion(), null);
});