From 34f1cb9e7cd54213beb8389e27cdb6a5ae7f0545 Mon Sep 17 00:00:00 2001 From: Fabian Mendoza Date: Fri, 10 Jul 2026 23:40:06 -0400 Subject: [PATCH] feat(dashboard): version indicator + GHCR update check (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --------- Co-authored-by: ScreenTinker Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/css/main.css | 19 ++++ frontend/index.html | 4 + frontend/js/app.js | 15 ++- frontend/js/views/admin.js | 118 ++++++++++++++++++++++- server/config.js | 6 ++ server/lib/ghcr-check.js | 171 +++++++++++++++++++++++++++++++++ server/routes/admin.js | 50 +++++++++- server/server.js | 9 +- server/test/api.test.js | 37 +++++++ server/test/ghcr-check.test.js | 64 ++++++++++++ 10 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 server/lib/ghcr-check.js create mode 100644 server/test/ghcr-check.test.js diff --git a/frontend/css/main.css b/frontend/css/main.css index 4edacf1..bc86c56 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -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; diff --git a/frontend/index.html b/frontend/index.html index 9a09957..f389ff1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -152,6 +152,10 @@ Disconnected +
+ + +
diff --git a/frontend/js/app.js b/frontend/js/app.js index a62ac8c..8036cea 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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); diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index bd35f95..d83cc57 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -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 + ? `
+
${t('admin.latest_version') || 'Latest Version'}
+
${esc(version.latest_version)}
+
+
+
${t('admin.status') || 'Status'}
+
${version.update_available ? (t('admin.update_available') || 'Update Available') : (t('admin.up_to_date') || 'Up to Date')}
+
` + : `
+
${t('admin.latest_version') || 'Latest Version'}
+
${t('admin.checking') || 'Checking...'}
+
`; + el.innerHTML = `
-
${t('admin.version')}
${version.version}
-
${t('admin.frontend_hash')}
${version.hash}
+
${t('admin.version')}
${esc(version.version)}
+ ${versionComparison}
-
+
+ + ${t('admin.download_db_backup')} ${t('admin.server_status')}
+
`; + + // 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 = ` +
+
+ ${data.success ? (t('admin.update_success') || 'Update Successful') : (t('admin.update_failed') || 'Update Failed')} + +
+
${esc(data.output || '')}
+
`; + 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 = ` +
+
+ ${t('admin.manual_update') || 'Manual Update Required'} + +
+

${t('admin.manual_update_desc') || 'Run this command on the server:'}

+
${esc(data.instructions)}
+
`; + 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 = `

${esc(err.message)}

`; } } diff --git a/server/config.js b/server/config.js index 7052028..954ceef 100644 --- a/server/config.js +++ b/server/config.js @@ -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 diff --git a/server/lib/ghcr-check.js b/server/lib/ghcr-check.js new file mode 100644 index 0000000..027e1be --- /dev/null +++ b/server/lib/ghcr-check.js @@ -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, +}; diff --git a/server/routes/admin.js b/server/routes/admin.js index a8a832e..d8b03ae 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -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; diff --git a/server/server.js b/server/server.js index ada5f26..8a8ac23 100644 --- a/server/server.js +++ b/server/server.js @@ -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) { diff --git a/server/test/api.test.js b/server/test/api.test.js index e31d460..bf22bc8 100644 --- a/server/test/api.test.js +++ b/server/test/api.test.js @@ -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'); +}); diff --git a/server/test/ghcr-check.test.js b/server/test/ghcr-check.test.js new file mode 100644 index 0000000..039a027 --- /dev/null +++ b/server/test/ghcr-check.test.js @@ -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); +});