mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Merge feat/ota-two-channel: serve a beta APK alongside stable, with a real switch back
This commit is contained in:
commit
6d33d00cd0
23
README.md
23
README.md
|
|
@ -202,6 +202,29 @@ including "already up to date", so the button never just appears to do nothing.
|
|||
invent permissions: if installs need a confirmation tap on that hardware, forcing still raises the
|
||||
dialog. It is the right button once you have fixed whatever was breaking the update.
|
||||
|
||||
#### Running a beta channel
|
||||
|
||||
By default an instance serves one APK to every display, at `/download/apk`. You can publish a second
|
||||
build alongside it and send it only to displays you choose:
|
||||
|
||||
1. Put the beta APK next to the stable one, as **`ScreenTinker-beta.apk`** (same locations as
|
||||
`ScreenTinker.apk` — `/data/` in a container, or the install root).
|
||||
2. Declare its version in a sidecar text file, **`ScreenTinker-beta.apk.version`**, containing just
|
||||
the version — e.g. `1.9.27-rc1`. This is required. The server cannot read the version out of an
|
||||
APK cheaply, and advertising a version that does not match the bytes it serves is how update
|
||||
loops start, so **a beta with no declared version is ignored entirely** and opted-in displays
|
||||
keep getting the stable build.
|
||||
3. Tick **Accept pre-release builds** on any display that should receive it.
|
||||
|
||||
Untick the box to move a display back to the release build — the server offers it the stable build
|
||||
even though it is technically "older" than the beta. Displays you never put on the channel are
|
||||
untouched by any of this.
|
||||
|
||||
> **Cut beta builds with the same `versionCode` as the stable release they branch from.** Android
|
||||
> refuses to install a lower `versionCode`, so a beta numbered above stable can be installed but
|
||||
> never returned without uninstalling the app (which loses the display's pairing). Equal numbers
|
||||
> install in both directions, which is what makes switching back work.
|
||||
|
||||
#### Deleting and re-pairing a display
|
||||
|
||||
A display's settings are keyed to the hardware, not to its row in the database. Delete a display and
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ export default {
|
|||
'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.',
|
||||
'device.ota.toggle': 'Self-update (OTA)',
|
||||
'device.ota.beta': 'Accept pre-release builds',
|
||||
'device.ota.beta_hint': 'Keeps this display on a test build instead of updating it back to the current release. Only affects pre-releases of the version already installed — once a newer release ships, this display updates to it normally.',
|
||||
'device.ota.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.',
|
||||
'device.ota.hint': 'When off, this device is never offered an update — an MDM or operator owns its updates instead. Turn OFF for MDM-managed panels (e.g. Pivot/MAXHUB) so the app never shows a self-install dialog.',
|
||||
'device.reboot_schedule.label': 'Nightly reboot',
|
||||
'device.reboot_schedule.hint': 'Reboot this panel once a day at this device-local time (leave blank for off). A clean nightly reboot clears memory leaks and re-syncs the clock. Silent on device-owner panels; a no-op on panels that can\'t self-reboot.',
|
||||
|
|
|
|||
|
|
@ -305,6 +305,11 @@ const migrations = [
|
|||
// within minutes. It cost a reporter on #234 an evening of testing code that had already been
|
||||
// replaced under them. Set this and the display keeps a same-core prerelease.
|
||||
"ALTER TABLE devices ADD COLUMN ota_beta INTEGER NOT NULL DEFAULT 0",
|
||||
// The channel we last SERVED this display. Needed to tell "an operator just switched this
|
||||
// display off beta" apart from "this display has always run a build of its own" — only the
|
||||
// first may be pulled back to stable. Without it, publishing a beta would drag every existing
|
||||
// pre-release tester backwards, which is the harm the opt-in exists to prevent.
|
||||
"ALTER TABLE devices ADD COLUMN ota_channel_served TEXT",
|
||||
// #161: privilege tier reported by the player (0 unprivileged / 1 device-admin / 2 owner-or-
|
||||
// delegated-install) + whether a foreign device owner (MDM) manages it. Drives dashboard gating
|
||||
// of Tier-2 controls (reboot/kiosk/time) — shown only for owned panels.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,20 @@
|
|||
// /download/apk does a per-request synchronous filesystem call. The path/size/mtime are
|
||||
// resolved once at boot and refreshed on an interval (like the frontend-hash refresh),
|
||||
// so a poll/download flood can't turn into an existsSync/statSync flood on the loop.
|
||||
//
|
||||
// Two channels. The STABLE slot is the APK every display gets. The BETA slot is optional and
|
||||
// only reaches displays with devices.ota_beta = 1.
|
||||
//
|
||||
// A beta build must DECLARE its version, in a sidecar `<apk>.version` file beside it. The server
|
||||
// cannot infer it: latest_version on the stable channel is the server's own VERSION constant
|
||||
// (server and APK ship together), but a beta APK is by definition a different version, and
|
||||
// reading it out of the APK would mean parsing binary AndroidManifest.xml on the request path.
|
||||
// A one-line text file is explicit, greppable, and cannot drift silently.
|
||||
//
|
||||
// If the sidecar is missing or unparseable the beta channel does NOT activate and opted-in
|
||||
// displays keep getting stable. Failing closed matters here: advertising a version that does not
|
||||
// match the bytes actually served is precisely the OTA-loop condition this fleet has been bitten
|
||||
// by before.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
|
@ -10,21 +24,54 @@ const config = require('../config');
|
|||
|
||||
// A copy under DATA_DIR wins (container operators mount /data/ScreenTinker.apk),
|
||||
// else the legacy in-repo root path — same order as the old resolveApkPath().
|
||||
function candidates() {
|
||||
return [path.join(config.dataDir, 'ScreenTinker.apk'), path.join(__dirname, '..', '..', 'ScreenTinker.apk')];
|
||||
function candidates(name) {
|
||||
return [path.join(config.dataDir, name), path.join(__dirname, '..', '..', name)];
|
||||
}
|
||||
|
||||
let cache = { path: null, exists: false, size: 0, mtime: 0 };
|
||||
const EMPTY = { path: null, exists: false, size: 0, mtime: 0, version: null };
|
||||
|
||||
let stable = { ...EMPTY };
|
||||
let beta = { ...EMPTY };
|
||||
|
||||
function statFirst(name) {
|
||||
for (const p of candidates(name)) {
|
||||
try {
|
||||
const st = fs.statSync(p);
|
||||
return { path: p, exists: true, size: st.size, mtime: st.mtimeMs, version: null };
|
||||
} catch (_) { /* next */ }
|
||||
}
|
||||
return { ...EMPTY };
|
||||
}
|
||||
|
||||
// Version declared alongside the APK. First non-empty line, trimmed; anything that is not a
|
||||
// plausible semver is treated as absent rather than trusted.
|
||||
function readDeclaredVersion(apkPath) {
|
||||
if (!apkPath) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(apkPath + '.version', 'utf8');
|
||||
const v = String(raw).split('\n')[0].trim();
|
||||
return /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v) ? v : null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
for (const p of candidates()) {
|
||||
try { const st = fs.statSync(p); cache = { path: p, exists: true, size: st.size, mtime: st.mtimeMs }; return cache; } catch (_) { /* next */ }
|
||||
}
|
||||
cache = { path: null, exists: false, size: 0, mtime: 0 };
|
||||
return cache;
|
||||
stable = statFirst('ScreenTinker.apk');
|
||||
const b = statFirst('ScreenTinker-beta.apk');
|
||||
b.version = b.exists ? readDeclaredVersion(b.path) : null;
|
||||
beta = b.exists && b.version ? b : { ...EMPTY }; // no declared version -> no beta channel
|
||||
return stable;
|
||||
}
|
||||
|
||||
function get() { return cache; }
|
||||
function get() { return stable; }
|
||||
function getBeta() { return beta; }
|
||||
|
||||
/** The slot to serve for a channel, falling back to stable whenever beta is not usable. */
|
||||
function forChannel(channel) {
|
||||
return channel === 'beta' && beta.exists ? beta : stable;
|
||||
}
|
||||
|
||||
/** Whether a usable beta build is published right now. */
|
||||
function betaAvailable() { return beta.exists && !!beta.version; }
|
||||
|
||||
let timer = null;
|
||||
function start() {
|
||||
|
|
@ -33,7 +80,7 @@ function start() {
|
|||
timer = setInterval(refresh, config.otaApkRefreshMs);
|
||||
if (timer.unref) timer.unref();
|
||||
}
|
||||
return cache;
|
||||
return stable;
|
||||
}
|
||||
|
||||
module.exports = { start, refresh, get };
|
||||
module.exports = { start, refresh, get, getBeta, forChannel, betaAvailable };
|
||||
|
|
|
|||
|
|
@ -68,14 +68,35 @@ function isReleased(p) { return p.pre === null || /^patch\d+$/i.test(p.pre); }
|
|||
|
||||
// decide(clientVersion, latestVersion, deviceId?, now?) ->
|
||||
// { update_available, reason, retry_after_seconds?, log? }
|
||||
function decide(clientVersion, latestVersion, deviceId = null, now = Date.now(), betaChannel = false) {
|
||||
function decide(clientVersion, latestVersion, deviceId = null, now = Date.now(), betaChannel = false, wasOnBeta = false) {
|
||||
// ---- PHANTOM / unrecognized guard (immediate, version-based, no rate state) ----
|
||||
if (!clientVersion) return { update_available: false, reason: 'no-version' };
|
||||
const pc = parseVer(clientVersion), pl = parseVer(latestVersion);
|
||||
if (!pc || !pl) return { update_available: false, reason: 'unrecognized-version', log: logOnce(clientVersion, `[ota] unrecognized client version '${clientVersion}' — no offer (latest=${latestVersion})`) };
|
||||
const full = cmpParsed(pc, pl);
|
||||
if (full === 0) return { update_available: false, reason: 'up-to-date' };
|
||||
if (full > 0) return { update_available: false, reason: 'client-newer' }; // never offer a downgrade
|
||||
if (full > 0) {
|
||||
// Normally a client ahead of the server is left alone — never offer a downgrade. But a display
|
||||
// running a PRE-RELEASE while not opted into betas is a display someone has just switched back
|
||||
// to the release line, and stable is legitimately "older" than the beta it is replacing. Without
|
||||
// this it is stranded on the beta build forever, and unticking the box would appear to do
|
||||
// nothing — the same silent no-op the opt-in exists to remove.
|
||||
//
|
||||
// A client ahead on a genuine RELEASE still gets client-newer: that is a rolled-back server, and
|
||||
// pushing it backwards would be wrong.
|
||||
//
|
||||
// NOTE: the server can only OFFER. Android refuses to install a lower versionCode, so a beta
|
||||
// build must be cut with a versionCode no higher than the stable it branches from — equal is
|
||||
// ideal, since equal codes install in both directions. A beta with a higher code cannot be
|
||||
// returned to stable without an uninstall, whatever this endpoint says.
|
||||
// wasOnBeta is the evidence that this display was actually being served the beta channel. A
|
||||
// display that has simply always run its own pre-release build is left alone, exactly as
|
||||
// before — #144's protection for a tester ahead of the server is untouched.
|
||||
if (!betaChannel && wasOnBeta && !isReleased(pc)) {
|
||||
return { update_available: true, reason: 'channel-return' };
|
||||
}
|
||||
return { update_available: false, reason: 'client-newer' };
|
||||
}
|
||||
// betaChannel is exempt: this guard would otherwise strand the very displays we hand test
|
||||
// builds to. A tester on 1.9.25-fix234d has an older core than a released 1.9.26, so without
|
||||
// the exemption they are told "superseded" forever and never rejoin the release line — the
|
||||
|
|
|
|||
|
|
@ -703,8 +703,9 @@ const { getBand } = require('./services/loop-lag'); // #146 Item C: critical-ba
|
|||
app.get('/api/update/check', (req, res) => {
|
||||
const currentVersion = req.query.version;
|
||||
const deviceId = req.query.device_id || null; // #144: optional; beta4+ clients send it for per-device keying
|
||||
const latestVersion = VERSION;
|
||||
let latestVersion = VERSION; // replaced by the beta build's declared version for opted-in displays
|
||||
let betaChannel = false; // per-display pre-release opt-in, set from the device row below
|
||||
let wasOnBeta = false; // whether we have actually served this display the beta channel
|
||||
|
||||
// #155/#161: self-update kill switch, enforced SERVER-SIDE so it covers EVERY client
|
||||
// version (not just ones with the client-side stand-down). If OTA is off globally
|
||||
|
|
@ -716,12 +717,13 @@ app.get('/api/update/check', (req, res) => {
|
|||
let otaDeviceOff = false;
|
||||
if (deviceId) {
|
||||
try {
|
||||
const row = require('./db/database').db.prepare('SELECT ota_enabled, ota_beta FROM devices WHERE id = ?').get(deviceId);
|
||||
const row = require('./db/database').db.prepare('SELECT ota_enabled, ota_beta, ota_channel_served FROM devices WHERE id = ?').get(deviceId);
|
||||
otaDeviceOff = !!row && row.ota_enabled === 0;
|
||||
// #234 follow-up: per-display pre-release opt-in, read from the same row rather than a
|
||||
// second query. Without it, handing someone a test build is a trap — a prerelease sorts
|
||||
// BELOW its own release, so the next check "upgrades" the display straight back off it.
|
||||
betaChannel = !!row && row.ota_beta === 1;
|
||||
wasOnBeta = !!row && row.ota_channel_served === 'beta';
|
||||
} catch (_) { /* device unknown / pre-migration — treat as enabled */ }
|
||||
}
|
||||
if (otaGloballyOff || otaDeviceOff) {
|
||||
|
|
@ -736,7 +738,23 @@ app.get('/api/update/check', (req, res) => {
|
|||
|
||||
// #144: circuit-breaker + phantom-version guard. Keys per device_id when present, else
|
||||
// per reported version (NOT IP — SNAT). Rate-trips a looping client in seconds.
|
||||
const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId, Date.now(), betaChannel);
|
||||
// Channel selection. An opted-in display is compared against — and offered — the BETA build's
|
||||
// declared version, not the server's. Falls back to stable whenever no usable beta is published,
|
||||
// so ticking the box on a server with no beta build is a no-op, not a broken display.
|
||||
const onBeta = betaChannel && apkCache.betaAvailable();
|
||||
if (onBeta) latestVersion = apkCache.getBeta().version;
|
||||
|
||||
// The hold-my-prerelease guard only applies when we are NOT actively serving a beta: on the beta
|
||||
// channel the beta build is the target, so normal comparison does the right thing.
|
||||
const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId, Date.now(), betaChannel && !onBeta, wasOnBeta);
|
||||
|
||||
// Record that this display is being served beta, so switching it back later is distinguishable
|
||||
// from a display that has always run its own build. Written only on a change, not per check.
|
||||
if (onBeta && !wasOnBeta && deviceId) {
|
||||
try {
|
||||
require('./db/database').db.prepare("UPDATE devices SET ota_channel_served = 'beta' WHERE id = ?").run(deviceId);
|
||||
} catch (_) { /* best-effort bookkeeping; never break a check over it */ }
|
||||
}
|
||||
|
||||
// #146 Item C: EARLY-RETURN before any filesystem work when we won't serve
|
||||
// (rate-backoff, up-to-date, phantom, client-newer, …). A looping client that gets
|
||||
|
|
@ -754,14 +772,17 @@ app.get('/api/update/check', (req, res) => {
|
|||
|
||||
// Offering — read the CACHED apk metadata (no per-request statSync; refreshed on an
|
||||
// interval by apkCache). Never offer if the APK isn't actually present.
|
||||
const apk = apkCache.get();
|
||||
const apk = onBeta ? apkCache.getBeta() : apkCache.get();
|
||||
const updateAvailable = apk.exists;
|
||||
if (verdict.log) console.log(verdict.log);
|
||||
logOtaCheck(deviceId, currentVersion, latestVersion, updateAvailable, updateAvailable ? verdict.reason : 'apk-missing');
|
||||
res.json({
|
||||
latest_version: latestVersion, current_version: currentVersion || 'unknown',
|
||||
update_available: updateAvailable, reason: updateAvailable ? verdict.reason : 'apk-missing',
|
||||
download_url: '/download/apk',
|
||||
// The client fetches whatever URL we hand back, so channel routing needs no APK change —
|
||||
// a display already in the field can be moved between channels from the dashboard.
|
||||
download_url: onBeta ? '/download/apk?channel=beta' : '/download/apk',
|
||||
channel: onBeta ? 'beta' : 'stable',
|
||||
apk_size: updateAvailable ? apk.size : 0,
|
||||
apk_modified: updateAvailable ? apk.mtime : 0,
|
||||
// #166 escape hatch (OTA_ALLOW_MANAGED_DEVICES). Tells a player it may self-update even when
|
||||
|
|
@ -1054,7 +1075,10 @@ const otaDownloadGuard = require('./lib/ota-download-guard');
|
|||
const otaDownloadState = otaDownloadGuard.prodState(); // #146 P3.8: shared singleton so /api/status can read stats
|
||||
|
||||
app.get('/download/apk', (req, res) => {
|
||||
const apk = apkCache.get();
|
||||
// Serve the slot the check advertised. If these disagree the client is handed bytes whose
|
||||
// size does not match apk_size, which is how an OTA loop starts — so both sides resolve
|
||||
// the channel the same way, and both fall back to stable identically.
|
||||
const apk = apkCache.forChannel(req.query.channel === 'beta' ? 'beta' : 'stable');
|
||||
if (!apk.exists) {
|
||||
return res.status(404).send(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>APK Not Available — ScreenTinker</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:480px;padding:32px 24px}h1{color:#f87171;font-size:22px;margin:0 0 8px}p{line-height:1.6;color:#94a3b8;font-size:14px;margin:0 0 20px}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}.btn{display:inline-block;background:#2563eb;color:#fff;padding:10px 20px;border-radius:6px;font-size:14px;font-weight:500;text-decoration:none;margin-bottom:24px}.btn:hover{background:#1d4ed8;text-decoration:none}.muted{font-size:12px;color:#64748b}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet.</p><a class="btn" href="https://github.com/screentinker/screentinker/releases/latest" target="_blank" rel="noopener">📦 Download from GitHub Releases</a><p class="muted">Self-hosting? Mount a built APK at <code>/data/ScreenTinker.apk</code> to serve it from this instance. Or use the <a href="/player">web player</a> instead.</p></div></body></html>`);
|
||||
}
|
||||
|
|
|
|||
128
server/test/ota-two-channel.test.js
Normal file
128
server/test/ota-two-channel.test.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
// Serving two APKs, and letting a display move between them.
|
||||
//
|
||||
// The passive opt-in shipped in 1.9.26 only stopped a sideloaded build being reverted. It did not
|
||||
// let the server DISTRIBUTE a beta: there was one APK slot, and latest_version was the server's own
|
||||
// VERSION, so a beta build had to be installed by hand on every display.
|
||||
//
|
||||
// Two things have to hold or this becomes an OTA loop rather than a feature:
|
||||
//
|
||||
// 1. The version advertised must match the bytes served. The check and the download resolve the
|
||||
// channel the same way and fall back to stable identically, and a beta with no declared
|
||||
// version does not activate at all.
|
||||
// 2. Switching back must actually move the display. Stable is semver-OLDER than the beta it
|
||||
// replaces, so the ordinary "never downgrade" rule strands it — unticking the box would be
|
||||
// another silent no-op.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-chan-'));
|
||||
process.env.DATA_DIR = tmp;
|
||||
|
||||
const apkCache = require('../lib/apk-cache');
|
||||
const breaker = require('../lib/ota-breaker');
|
||||
|
||||
const STABLE = path.join(tmp, 'ScreenTinker.apk');
|
||||
const BETA = path.join(tmp, 'ScreenTinker-beta.apk');
|
||||
|
||||
function writeStable() { fs.writeFileSync(STABLE, Buffer.alloc(100, 1)); }
|
||||
function writeBeta(version) {
|
||||
fs.writeFileSync(BETA, Buffer.alloc(250, 2));
|
||||
if (version === null) { try { fs.unlinkSync(BETA + '.version'); } catch (_) {} }
|
||||
else fs.writeFileSync(BETA + '.version', version + '\n');
|
||||
}
|
||||
function clearBeta() {
|
||||
try { fs.unlinkSync(BETA); } catch (_) {}
|
||||
try { fs.unlinkSync(BETA + '.version'); } catch (_) {}
|
||||
}
|
||||
|
||||
const ask = (client, latest, beta, wasOnBeta = false) =>
|
||||
breaker.decide(client, latest, null, Date.now(), beta, wasOnBeta);
|
||||
|
||||
test('with no beta published, the beta channel simply is not available', () => {
|
||||
writeStable(); clearBeta(); apkCache.refresh();
|
||||
assert.equal(apkCache.betaAvailable(), false);
|
||||
// Ticking the box on a server with no beta build must be a no-op, not a broken display.
|
||||
assert.equal(apkCache.forChannel('beta').path, STABLE, 'must fall back to stable');
|
||||
});
|
||||
|
||||
test('a beta APK with NO declared version does not activate', () => {
|
||||
// Failing closed: the server cannot know what version those bytes are, and advertising a
|
||||
// version that does not match what is served is how an OTA loop starts.
|
||||
writeStable(); writeBeta(null); apkCache.refresh();
|
||||
assert.equal(apkCache.betaAvailable(), false, 'an undeclared beta must be ignored entirely');
|
||||
assert.equal(apkCache.forChannel('beta').path, STABLE);
|
||||
});
|
||||
|
||||
test('a beta APK with a junk version file is treated as absent, not trusted', () => {
|
||||
writeStable(); fs.writeFileSync(BETA, Buffer.alloc(250, 2));
|
||||
fs.writeFileSync(BETA + '.version', 'latest\n');
|
||||
apkCache.refresh();
|
||||
assert.equal(apkCache.betaAvailable(), false);
|
||||
});
|
||||
|
||||
test('a properly declared beta activates and is served on the beta channel only', () => {
|
||||
writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh();
|
||||
assert.equal(apkCache.betaAvailable(), true);
|
||||
assert.equal(apkCache.getBeta().version, '1.9.27-rc1');
|
||||
assert.equal(apkCache.forChannel('beta').path, BETA);
|
||||
assert.equal(apkCache.forChannel('stable').path, STABLE, 'stable displays must be unaffected');
|
||||
assert.equal(apkCache.get().path, STABLE);
|
||||
});
|
||||
|
||||
test('the two slots report their own sizes, so apk_size matches the bytes served', () => {
|
||||
writeStable(); writeBeta('1.9.27-rc1'); apkCache.refresh();
|
||||
assert.equal(apkCache.forChannel('stable').size, 100);
|
||||
assert.equal(apkCache.forChannel('beta').size, 250);
|
||||
assert.notEqual(apkCache.get().size, apkCache.getBeta().size);
|
||||
});
|
||||
|
||||
test('an opted-in display is offered the beta build over the current stable', () => {
|
||||
// The check compares against the beta's declared version, not the server's.
|
||||
const v = ask('1.9.26', '1.9.27-rc1', false);
|
||||
assert.equal(v.update_available, true);
|
||||
assert.equal(v.reason, 'offer');
|
||||
});
|
||||
|
||||
test('once on the beta build, an opted-in display is up to date', () => {
|
||||
assert.equal(ask('1.9.27-rc1', '1.9.27-rc1', false).reason, 'up-to-date');
|
||||
});
|
||||
|
||||
test('THE SWITCH BACK: unticking beta moves a display off the beta build', () => {
|
||||
// Stable 1.9.26 is semver-OLDER than 1.9.27-rc1, so the plain "never downgrade" rule would
|
||||
// strand it and unticking the box would appear to do nothing. wasOnBeta is the evidence that
|
||||
// we actually served this display the beta channel.
|
||||
const v = ask('1.9.27-rc1', '1.9.26', false, true);
|
||||
assert.equal(v.update_available, true, 'the display must be offered the release build');
|
||||
assert.equal(v.reason, 'channel-return');
|
||||
});
|
||||
|
||||
test('a display we never served beta to is NOT pulled back, even on a pre-release', () => {
|
||||
// #144 protects a tester who is ahead of the server on their own build. Publishing a beta must
|
||||
// not drag every such display backwards — that is the exact harm the opt-in exists to prevent.
|
||||
const v = ask('1.9.4-beta1', '1.9.3', false, false);
|
||||
assert.equal(v.update_available, false);
|
||||
assert.equal(v.reason, 'client-newer');
|
||||
});
|
||||
|
||||
test('a display AHEAD on a real release is still never downgraded', () => {
|
||||
// This is a rolled-back server, not a channel switch. Pushing it backwards would be wrong.
|
||||
const v = ask('1.9.27', '1.9.26', false);
|
||||
assert.equal(v.update_available, false);
|
||||
assert.equal(v.reason, 'client-newer');
|
||||
});
|
||||
|
||||
test('a still-opted-in display on a newer prerelease is NOT dragged back to stable', () => {
|
||||
// Only unticking the box should return it. While opted in it keeps its beta build, even though
|
||||
// we have served it beta before.
|
||||
const v = ask('1.9.27-rc1', '1.9.26', true, true);
|
||||
assert.equal(v.update_available, false);
|
||||
assert.equal(v.reason, 'client-newer');
|
||||
});
|
||||
|
||||
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });
|
||||
Loading…
Reference in a new issue