mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
700 lines
40 KiB
JavaScript
700 lines
40 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import { SoftwareStatusFeed, SoftwarePage, validSoftwareSnapshot, softwareProgress } from "../web/js/software-status.js"
|
|
|
|
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
|
|
const installed = { version: "fixture", branch: "Dom", commit: "a".repeat(40) }
|
|
const updater = { state: "idle", targetBranch: "Dom", lastSuccessAt: "2026-09-20T12:30:00Z", lastFetchAt: null,
|
|
targetChangeFound: true, finalizedUpdateReady: false, failedCount: 0 }
|
|
const operations = { parked: true, availableBranches: ["Dom", "beta"], selectedTarget: "Dom",
|
|
canCheck: true, canDownload: true, canSelect: true, canInstall: false, reason: null, request: null }
|
|
const snapshot = (changes = {}) => ({ schemaVersion: 1, installed, updater, operations, ...changes })
|
|
const request = (action, state = "pending", target = null, error = null) => ({ id: "one", action, target, state, error })
|
|
const response = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
|
|
|
|
function fixture(includeHistory = true, restarting = () => {}, preparing = () => {}, cancelPreparation = () => {}) {
|
|
const requests = [], states = [], timers = new Map()
|
|
let nextTimer = 0, unauthorized = 0
|
|
const feed = new SoftwareStatusFeed({ includeHistory, publish: (state) => states.push(state), unauthorized: () => { unauthorized++ },
|
|
restarting, preparing, cancelPreparation,
|
|
later: (fn, ms) => { const id = ++nextTimer; timers.set(id, { fn, ms }); return id },
|
|
cancelTimer: (id) => timers.delete(id),
|
|
fetcher: (url, options) => new Promise((resolve, reject) => requests.push({ url, options, resolve, reject })) })
|
|
async function reply(index, body, status = 200) { requests[index].resolve(response(body, status)); await flush() }
|
|
async function fail(index) { requests[index].reject(new Error("Connection lost")); await flush() }
|
|
function fire(ms) {
|
|
const entry = [...timers.entries()].find(([, timer]) => timer.ms === ms)
|
|
assert.ok(entry, `missing ${ms} ms timer`)
|
|
timers.delete(entry[0]); entry[1].fn()
|
|
}
|
|
return { feed, requests, states, timers, reply, fail, fire, get unauthorized() { return unauthorized } }
|
|
}
|
|
|
|
assert.ok(validSoftwareSnapshot(snapshot()))
|
|
assert.ok(validSoftwareSnapshot(snapshot({ installed: { ...installed, displayVersion: "StarPilot 0.11.2" } })))
|
|
assert.equal(validSoftwareSnapshot(snapshot({ installed: { ...installed, displayVersion: 12 } })), false)
|
|
assert.ok(validSoftwareSnapshot({ schemaVersion: 1, installed, updater })) // Home can consume status-only responses.
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canInstall: "yes" } })), false)
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, availableBranches: [42] } })), false)
|
|
assert.ok(validSoftwareSnapshot(snapshot({ operations: { ...operations, availableBranches: ["x".repeat(128)] } })))
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, availableBranches: ["x".repeat(129)] } })), false)
|
|
assert.ok(validSoftwareSnapshot(snapshot({ operations: { ...operations,
|
|
availableBranches: Array.from({ length: 76 }, (_, index) => `published-${index}`) } })))
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations,
|
|
availableBranches: Array.from({ length: 257 }, (_, index) => `published-${index}`) } })), false)
|
|
|
|
const statusOnly = fixture()
|
|
statusOnly.feed.start()
|
|
await statusOnly.reply(0, { schemaVersion: 1, installed, updater })
|
|
assert.equal(statusOnly.states.at(-1).status, "ready")
|
|
assert.equal(statusOnly.states.at(-1).data.updater.finalizedUpdateReady, false)
|
|
assert.equal(statusOnly.states.at(-1).data.operations.canCheck, false)
|
|
statusOnly.feed.action("check")
|
|
assert.equal(statusOnly.requests.length, 1)
|
|
statusOnly.feed.stop()
|
|
|
|
const unavailable = fixture()
|
|
unavailable.feed.start()
|
|
assert.equal(unavailable.states.at(-1).status, "loading")
|
|
await unavailable.reply(0, { error: "Software status is unavailable" }, 503)
|
|
assert.equal(unavailable.states.at(-1).status, "loading")
|
|
assert.equal(unavailable.states.at(-1).error, "")
|
|
unavailable.fire(5000)
|
|
assert.equal(unavailable.states.at(-1).status, "loading")
|
|
await unavailable.reply(1, snapshot())
|
|
assert.equal(unavailable.states.at(-1).status, "ready")
|
|
assert.equal(unavailable.states.at(-1).error, "")
|
|
unavailable.feed.stop()
|
|
|
|
const persistentUnavailable = fixture()
|
|
persistentUnavailable.feed.start()
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
await persistentUnavailable.reply(attempt, { error: "Updater status is unavailable" }, 503)
|
|
if (attempt < 2) {
|
|
assert.equal(persistentUnavailable.states.at(-1).status, "loading")
|
|
persistentUnavailable.fire(5000)
|
|
}
|
|
}
|
|
assert.equal(persistentUnavailable.states.at(-1).status, "unavailable")
|
|
assert.match(persistentUnavailable.states.at(-1).error, /Updater status is unavailable/)
|
|
persistentUnavailable.feed.stop()
|
|
|
|
const restartScreens = []
|
|
const preparedUpdates = []
|
|
const restartingUpdate = fixture(true, baseline => restartScreens.push(baseline), baseline => preparedUpdates.push(baseline))
|
|
restartingUpdate.feed.start()
|
|
await restartingUpdate.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
restartingUpdate.feed.action("update", "Dom")
|
|
assert.equal(preparedUpdates.length, 1, "update intent is saved before the request can disconnect")
|
|
await restartingUpdate.reply(1, snapshot({ operations: { ...operations, canFastUpdate: true, request: request("update", "pending", "Dom") } }))
|
|
assert.deepEqual(restartScreens, [], "accepted updates keep their progress page visible")
|
|
restartingUpdate.fire(1000)
|
|
await restartingUpdate.reply(2, snapshot({ updater: { ...updater, state: "updating..." },
|
|
operations: { ...operations, updaterAvailable: false, progress: { stage: "unavailable", detail: "Waiting for the device updater service…", percent: null },
|
|
request: request("update", "pending", "Dom") } }))
|
|
assert.equal(restartScreens.at(-1).target, "Dom", "loss of the updater service during an update starts reconnecting immediately")
|
|
restartingUpdate.feed.stop()
|
|
|
|
const delayedRestarts = []
|
|
const delayedRestart = fixture(true, baseline => delayedRestarts.push(baseline))
|
|
delayedRestart.feed.start()
|
|
await delayedRestart.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
delayedRestart.feed.action("update", "Dom")
|
|
await delayedRestart.reply(1, snapshot({ operations: { ...operations, canFastUpdate: true, request: request("update", "pending", "Dom") } }))
|
|
delayedRestart.fire(1000)
|
|
await delayedRestart.reply(2, { error: "Updater status is unavailable" }, 503)
|
|
assert.deepEqual(delayedRestarts, [], "an updater status error does not mean the device restarted")
|
|
delayedRestart.fire(2000)
|
|
await delayedRestart.fail(3)
|
|
assert.equal(delayedRestarts.at(-1).target, "Dom", "connection loss starts the reconnect screen")
|
|
delayedRestart.feed.stop()
|
|
|
|
let canceledPreparation = 0
|
|
const rejectedUpdate = fixture(true, () => {}, () => {}, () => canceledPreparation++)
|
|
rejectedUpdate.feed.start()
|
|
await rejectedUpdate.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
rejectedUpdate.feed.action("update", "Dom")
|
|
await rejectedUpdate.reply(1, { error: "Update rejected" }, 409)
|
|
assert.equal(canceledPreparation, 1)
|
|
rejectedUpdate.feed.stop()
|
|
|
|
const normal = fixture()
|
|
normal.feed.start()
|
|
assert.equal(normal.requests[0].url, "./api/software/status")
|
|
assert.equal(normal.requests[0].options.credentials, "same-origin")
|
|
await normal.reply(0, snapshot())
|
|
assert.equal(normal.states.at(-1).busy, false)
|
|
assert.deepEqual([...normal.timers.values()].map((timer) => timer.ms), [5000])
|
|
normal.feed.load()
|
|
assert.equal(normal.states.at(-1).status, "ready")
|
|
assert.deepEqual(normal.states.at(-1).data.installed, installed) // Refresh keeps the visible build.
|
|
assert.equal(normal.states.at(-1).busy, false) // Background reads do not dim the controls.
|
|
await normal.reply(1, snapshot({ updater: { ...updater, targetChangeFound: false } }))
|
|
assert.equal(normal.states.at(-1).data.updater.targetChangeFound, false)
|
|
normal.feed.stop()
|
|
assert.equal(normal.timers.size, 0)
|
|
|
|
const interruptPoll = fixture()
|
|
interruptPoll.feed.start()
|
|
await interruptPoll.reply(0, snapshot())
|
|
interruptPoll.fire(5000)
|
|
assert.equal(interruptPoll.states.at(-1).busy, false)
|
|
interruptPoll.feed.action("check")
|
|
assert.equal(interruptPoll.requests.length, 3)
|
|
assert.equal(interruptPoll.requests[1].options.signal.aborted, true)
|
|
assert.deepEqual(JSON.parse(interruptPoll.requests[2].options.body), { action: "check" })
|
|
assert.equal(interruptPoll.states.at(-1).busy, true)
|
|
interruptPoll.feed.action("check")
|
|
interruptPoll.feed.load()
|
|
assert.equal(interruptPoll.requests.length, 3)
|
|
await interruptPoll.reply(2, snapshot({ operations: { ...operations, request: request("check") } }))
|
|
await interruptPoll.reply(1, snapshot({ operations: { ...operations, parked: false } }))
|
|
assert.equal(interruptPoll.states.at(-1).data.operations.parked, true)
|
|
assert.equal(interruptPoll.states.at(-1).data.operations.request.state, "pending")
|
|
interruptPoll.feed.stop()
|
|
|
|
const branch = fixture()
|
|
branch.feed.start()
|
|
await branch.reply(0, snapshot())
|
|
branch.feed.action("select", "unknown")
|
|
assert.equal(branch.requests.length, 1)
|
|
branch.feed.action("select", "beta")
|
|
assert.equal(branch.requests[1].url, "./api/software/action")
|
|
assert.deepEqual(JSON.parse(branch.requests[1].options.body), { action: "select", branch: "beta" })
|
|
branch.feed.action("check")
|
|
assert.equal(branch.requests.length, 2) // In-flight mutation is never duplicated.
|
|
await branch.reply(1, snapshot({ operations: { ...operations, selectedTarget: "beta", request: request("select", "complete", "beta") } }))
|
|
assert.equal(branch.states.at(-1).busy, false)
|
|
assert.equal(branch.requests.length, 2) // Staging does not start a check or download.
|
|
branch.feed.action("download", "Dom")
|
|
assert.equal(branch.requests.length, 2)
|
|
branch.feed.action("check")
|
|
assert.deepEqual(JSON.parse(branch.requests[2].options.body), { action: "check" })
|
|
await branch.reply(2, snapshot({ operations: { ...operations, selectedTarget: "beta", request: request("check", "pending", "beta") } }))
|
|
assert.equal(branch.timers.size, 1)
|
|
branch.fire(1000)
|
|
assert.equal(branch.requests[3].url, "./api/software/status")
|
|
await branch.reply(3, snapshot({ operations: { ...operations, selectedTarget: "beta", request: request("check", "complete", "beta") } }))
|
|
assert.deepEqual([...branch.timers.values()].map((timer) => timer.ms), [5000])
|
|
branch.feed.action("download", "beta")
|
|
assert.deepEqual(JSON.parse(branch.requests[4].options.body), { action: "download", branch: "beta" })
|
|
await branch.reply(4, snapshot({ operations: { ...operations, selectedTarget: "beta", request: request("download", "failed", "beta", "Download failed") } }))
|
|
assert.equal(branch.states.at(-1).data.operations.request.error, "Download failed")
|
|
assert.equal(branch.states.at(-1).busy, false)
|
|
branch.feed.stop()
|
|
|
|
const parked = fixture()
|
|
parked.feed.start()
|
|
await parked.reply(0, snapshot({ operations: { ...operations, parked: false, canCheck: false, canDownload: false, canSelect: false } }))
|
|
parked.feed.action("check")
|
|
assert.equal(parked.requests.length, 1)
|
|
parked.fire(1000)
|
|
assert.equal(parked.states.at(-1).status, "ready")
|
|
await parked.reply(1, snapshot())
|
|
assert.equal(parked.states.at(-1).data.operations.parked, true)
|
|
assert.deepEqual([...parked.timers.values()].map((timer) => timer.ms), [5000])
|
|
parked.feed.stop()
|
|
|
|
const manualVehicleRefresh = fixture()
|
|
manualVehicleRefresh.feed.start()
|
|
await manualVehicleRefresh.reply(0, snapshot({ operations: { ...operations, parked: false,
|
|
canCheck: false, canDownload: false, canSelect: false } }))
|
|
manualVehicleRefresh.feed.load()
|
|
assert.equal(manualVehicleRefresh.requests[1].url, "./api/software/status")
|
|
assert.equal(manualVehicleRefresh.requests[1].options.cache, "no-store")
|
|
assert.equal(manualVehicleRefresh.requests[1].options.method, undefined)
|
|
await manualVehicleRefresh.reply(1, snapshot())
|
|
assert.equal(manualVehicleRefresh.states.at(-1).data.operations.canCheck, true)
|
|
manualVehicleRefresh.feed.load()
|
|
manualVehicleRefresh.feed.load()
|
|
assert.equal(manualVehicleRefresh.requests[2].options.signal.aborted, true)
|
|
await manualVehicleRefresh.reply(3, snapshot({ operations: { ...operations, parked: false,
|
|
canCheck: false, canDownload: false, canSelect: false } }))
|
|
await manualVehicleRefresh.reply(2, snapshot())
|
|
assert.equal(manualVehicleRefresh.states.at(-1).data.operations.parked, false)
|
|
assert.equal(manualVehicleRefresh.states.at(-1).data.operations.canCheck, false)
|
|
assert.deepEqual(manualVehicleRefresh.states.at(-1).data.installed, installed)
|
|
manualVehicleRefresh.feed.action("check")
|
|
assert.equal(manualVehicleRefresh.requests.length, 4)
|
|
assert.ok(manualVehicleRefresh.requests.every(({ options }) => options.method === undefined))
|
|
manualVehicleRefresh.feed.stop()
|
|
assert.equal(manualVehicleRefresh.timers.size, 0)
|
|
|
|
const lateUpdater = fixture()
|
|
lateUpdater.feed.start()
|
|
await lateUpdater.reply(0, snapshot({ operations: { ...operations, canCheck: false, canDownload: false,
|
|
canSelect: false, reason: "Updater is not running" } }))
|
|
assert.deepEqual([...lateUpdater.timers.values()].map((timer) => timer.ms), [5000])
|
|
lateUpdater.fire(5000)
|
|
assert.equal(lateUpdater.requests[1].url, "./api/software/status")
|
|
assert.equal(lateUpdater.states.at(-1).data.operations.canCheck, false) // Keep the visible status during refresh.
|
|
await lateUpdater.reply(1, snapshot())
|
|
assert.equal(lateUpdater.states.at(-1).data.operations.canCheck, true)
|
|
assert.deepEqual([...lateUpdater.timers.values()].map((timer) => timer.ms), [5000])
|
|
lateUpdater.feed.stop()
|
|
assert.equal(lateUpdater.timers.size, 0)
|
|
|
|
const uncertain = fixture()
|
|
uncertain.feed.start()
|
|
await uncertain.reply(0, snapshot({ operations: { ...operations, request: { ...request("check", "complete", "Dom"), id: "old" } } }))
|
|
uncertain.feed.action("check")
|
|
await uncertain.fail(1)
|
|
assert.equal(uncertain.states.at(-1).uncertain, true)
|
|
assert.equal(uncertain.states.at(-1).data.installed.commit, installed.commit)
|
|
uncertain.feed.action("check")
|
|
assert.equal(uncertain.requests.length, 2)
|
|
uncertain.fire(2000)
|
|
await uncertain.reply(2, snapshot({ operations: { ...operations, request: { ...request("check", "complete", "Dom"), id: "old" } } }))
|
|
assert.equal(uncertain.states.at(-1).uncertain, true) // A retained older request does not resolve the new one.
|
|
uncertain.feed.action("check")
|
|
assert.equal(uncertain.requests.length, 3)
|
|
uncertain.fire(1000)
|
|
await uncertain.reply(3, snapshot({ operations: { ...operations, request: request("check", "pending", "Dom") } }))
|
|
assert.equal(uncertain.states.at(-1).uncertain, false)
|
|
assert.equal(uncertain.timers.size, 1)
|
|
uncertain.feed.stop()
|
|
assert.equal(uncertain.timers.size, 0)
|
|
|
|
const serverError = fixture()
|
|
serverError.feed.start()
|
|
await serverError.reply(0, snapshot())
|
|
serverError.feed.action("check")
|
|
await serverError.reply(1, { error: "Status unavailable after signal" }, 503)
|
|
assert.equal(serverError.states.at(-1).uncertain, true) // A server error does not prove the POST had no effect.
|
|
assert.equal(serverError.states.at(-1).busy, false)
|
|
serverError.feed.action("check")
|
|
assert.equal(serverError.requests.length, 2)
|
|
serverError.feed.stop()
|
|
|
|
const uncertainSelect = fixture()
|
|
uncertainSelect.feed.start()
|
|
await uncertainSelect.reply(0, snapshot())
|
|
uncertainSelect.feed.action("select", "beta")
|
|
await uncertainSelect.fail(1)
|
|
assert.equal(uncertainSelect.states.at(-1).uncertain, true)
|
|
uncertainSelect.fire(2000)
|
|
await uncertainSelect.reply(2, snapshot({ operations: { ...operations, selectedTarget: "beta" } }))
|
|
assert.equal(uncertainSelect.states.at(-1).uncertain, false) // Select stages target without a request record.
|
|
uncertainSelect.feed.stop()
|
|
|
|
const install = fixture()
|
|
install.feed.start()
|
|
await install.reply(0, snapshot({ operations: { ...operations, canInstall: true } }))
|
|
install.feed.action("install", "Dom")
|
|
assert.deepEqual(JSON.parse(install.requests[1].options.body), { action: "install", branch: "Dom" })
|
|
await install.reply(1, snapshot({ operations: { ...operations, canInstall: false, request: request("install", "pending", "Dom") } }))
|
|
assert.match(install.states.at(-1).notice, /Waiting to reconnect/)
|
|
install.fire(1000)
|
|
await install.fail(2)
|
|
assert.equal(install.states.at(-1).data.installed.commit, installed.commit)
|
|
assert.equal(install.timers.size, 1)
|
|
install.fire(2000)
|
|
await install.reply(3, snapshot({ installed: { ...installed, commit: "b".repeat(40) },
|
|
operations: { ...operations, canInstall: false } }))
|
|
assert.match(install.states.at(-1).notice, /verified after reconnect/)
|
|
assert.deepEqual([...install.timers.values()].map((timer) => timer.ms), [5000])
|
|
install.feed.stop()
|
|
|
|
const branchSwitch = fixture()
|
|
branchSwitch.feed.start()
|
|
await branchSwitch.reply(0, snapshot({ operations: { ...operations, selectedTarget: "beta", canInstall: true } }))
|
|
branchSwitch.feed.action("install", "beta")
|
|
await branchSwitch.reply(1, snapshot({ operations: { ...operations, selectedTarget: "beta", canInstall: false,
|
|
request: request("install", "pending", "beta") } }))
|
|
branchSwitch.fire(1000)
|
|
await branchSwitch.reply(2, snapshot({ installed: { ...installed, branch: "beta" },
|
|
operations: { ...operations, selectedTarget: "beta", canInstall: false } }))
|
|
assert.match(branchSwitch.states.at(-1).notice, /verified after reconnect/)
|
|
assert.equal(branchSwitch.states.at(-1).busy, false)
|
|
branchSwitch.feed.stop()
|
|
|
|
const revoked = fixture()
|
|
revoked.feed.start()
|
|
await revoked.reply(0, { error: "Sign in" }, 401)
|
|
assert.equal(revoked.unauthorized, 1)
|
|
assert.equal(revoked.states.at(-1).status, "idle")
|
|
|
|
const page = (selectedTarget = "Dom", availableBranches = ["StarPilot", "Dom", "beta"], installedBranch = "Dom") => {
|
|
const state = { operations: { ...operations, selectedTarget, availableBranches }, data: {
|
|
installed: { ...installed, branch: installedBranch } }, actionDisabled: false,
|
|
draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null }
|
|
Object.defineProperty(state,"branchOptions",{get:()=>SoftwarePage.computed.branchOptions.call(state)})
|
|
Object.defineProperty(state,"otherBranches",{get:()=>SoftwarePage.computed.otherBranches.call(state)})
|
|
Object.defineProperty(state,"primaryBranchHelp",{get:()=>SoftwarePage.computed.primaryBranchHelp.call(state)})
|
|
Object.defineProperty(state,"canStageBranch",{get:()=>SoftwarePage.computed.canStageBranch.call(state)})
|
|
return state
|
|
}
|
|
const staged = page()
|
|
SoftwarePage.methods.syncDraftBranch.call(staged,"Dom")
|
|
assert.equal(staged.draftBranch,"Dom")
|
|
staged.draftBranch="beta"
|
|
SoftwarePage.methods.changeBranch.call(staged)
|
|
assert.equal(staged.draftTouched,true)
|
|
assert.equal(staged.canStageBranch,true)
|
|
SoftwarePage.methods.chooseBranch.call(staged)
|
|
assert.equal(staged.dialog.action,"select") // Normal updates can still stage a target without restarting.
|
|
assert.equal(staged.dialog.branch,"beta")
|
|
SoftwarePage.methods.askUpdate.call({...staged, operations:{...staged.operations,canFastUpdate:true}})
|
|
const switchPage={...staged,operations:{...staged.operations,canFastUpdate:true}}
|
|
SoftwarePage.methods.askUpdate.call(switchPage)
|
|
assert.equal(switchPage.dialog.action,"update")
|
|
assert.equal(switchPage.dialog.branch,"beta")
|
|
assert.equal(switchPage.dialog.label,"Update & restart")
|
|
const alternateTarget=page("legacy",["StarPilot","Dom","beta"],"installed-only")
|
|
SoftwarePage.methods.syncDraftBranch.call(alternateTarget,"legacy")
|
|
assert.equal(alternateTarget.canStageBranch,false)
|
|
assert.equal(alternateTarget.draftBranch,"installed-only")
|
|
assert.deepEqual(alternateTarget.branchOptions.map(branch=>branch.name),["legacy","installed-only","StarPilot","Dom","beta"])
|
|
assert.equal(alternateTarget.branchOptions[0].available,false)
|
|
assert.equal(alternateTarget.branchOptions[1].available,true)
|
|
const sentinel = fixture()
|
|
sentinel.feed.start()
|
|
await sentinel.reply(0, snapshot({ operations: { ...operations, availableBranches: ["Dom", "other:"] } }))
|
|
sentinel.feed.action("select", "other:")
|
|
assert.equal(sentinel.requests.length, 1)
|
|
assert.deepEqual(page("Dom", ["Dom", "other:"]).branchOptions.map(branch=>branch.name), ["Dom"])
|
|
sentinel.feed.stop()
|
|
|
|
assert.equal(SoftwarePage.methods.branchLabel("StarPilot"), "Stable — StarPilot")
|
|
assert.equal(SoftwarePage.methods.branchLabel("Dom"), "Development — Dom")
|
|
|
|
const namedBranches = page("StarPilot", ["StarPilot", "Dom", "beta"], "StarPilot")
|
|
SoftwarePage.methods.syncDraftBranch.call(namedBranches, "StarPilot")
|
|
assert.equal(namedBranches.primaryChoice, "StarPilot")
|
|
assert.equal(namedBranches.draftBranch, "StarPilot")
|
|
const otherBranches = page("beta", ["StarPilot", "Dom", "beta"], "Dom")
|
|
SoftwarePage.methods.syncDraftBranch.call(otherBranches, "beta")
|
|
assert.equal(otherBranches.primaryChoice, "other:")
|
|
assert.equal(otherBranches.draftBranch, "beta")
|
|
assert.deepEqual(otherBranches.otherBranches.map(branch => branch.name), ["beta"])
|
|
otherBranches.primaryChoice = "Dom"
|
|
assert.equal(otherBranches.primaryBranchHelp, "Latest features and fixes under development. Updates regularly and may introduce bugs.")
|
|
otherBranches.draftBranch = "beta"
|
|
otherBranches.primaryChoice = "other:"
|
|
SoftwarePage.methods.onPrimaryBranchChange.call(otherBranches)
|
|
assert.equal(otherBranches.draftBranch, "beta")
|
|
assert.match(SoftwarePage.template, /Stable — StarPilot/)
|
|
assert.match(SoftwarePage.template, /Development — Dom/)
|
|
assert.match(SoftwarePage.template, /Other branches…/)
|
|
assert.match(SoftwarePage.template, /onPrimaryBranchChange/)
|
|
assert.match(SoftwarePage.template, /Check for updates/)
|
|
assert.match(SoftwarePage.template, /Download only/)
|
|
assert.match(SoftwarePage.template, /@click="askUpdate">Update<\/button>/)
|
|
assert.match(SoftwarePage.template, /displayVersion \?\? data\.installed\.version/)
|
|
assert.match(SoftwarePage.template, /@click="askRollback">Previous version/ )
|
|
assert.match(SoftwarePage.template, /operations\.canRollback !== true/)
|
|
assert.doesNotMatch(SoftwarePage.template, /historical version|status only/i)
|
|
|
|
const preferences = fixture()
|
|
preferences.feed.start()
|
|
await preferences.reply(0, snapshot({ operations: { ...operations, automaticDownloads: true, canConfigure: true } }))
|
|
preferences.feed.configureAutomaticDownloads(false)
|
|
assert.deepEqual(JSON.parse(preferences.requests[1].options.body), {
|
|
action: 'preferences', automaticDownloads: false, expectedAutomaticDownloads: true,
|
|
})
|
|
await preferences.fail(1)
|
|
assert.equal(preferences.states.at(-1).uncertain, true)
|
|
preferences.feed.configureAutomaticDownloads(true)
|
|
assert.equal(preferences.requests.length, 2)
|
|
preferences.fire(2000)
|
|
await preferences.reply(2, snapshot({ operations: { ...operations, automaticDownloads: false, canConfigure: true } }))
|
|
assert.equal(preferences.states.at(-1).uncertain, false)
|
|
assert.equal(preferences.states.at(-1).data.operations.automaticDownloads, false)
|
|
preferences.feed.stop()
|
|
|
|
const history = { installed: [{ hash: 'a'.repeat(40), date: '2026-09-29T12:30:00Z', subject: '<script>plain text</script>' }],
|
|
downloaded: [], currentReleaseNotes: '<script>also plain text</script>', downloadedReleaseNotes: null }
|
|
assert(validSoftwareSnapshot(snapshot({ operations: { ...operations, automaticDownloads: true, canConfigure: true, history } })))
|
|
assert(!validSoftwareSnapshot(snapshot({ operations: { ...operations, history: { ...history, installed: [{ hash: '--help' }] } } })))
|
|
assert(!validSoftwareSnapshot(snapshot({ operations: { ...operations, automaticDownloads: 'true' } })))
|
|
assert.doesNotMatch(SoftwarePage.template, /v-html/)
|
|
|
|
const fast = fixture()
|
|
fast.feed.start()
|
|
await fast.reply(0, snapshot({ operations: { ...operations, availableBranches: [], selectedTarget: "beta", canFastUpdate: true } }))
|
|
fast.feed.action("fast", "beta")
|
|
assert.equal(fast.requests.length, 1)
|
|
fast.feed.action("fast", installed.branch)
|
|
assert.deepEqual(JSON.parse(fast.requests[1].options.body), { action: "fast", branch: installed.branch })
|
|
await fast.reply(1, snapshot({ operations: { ...operations, canFastUpdate: false, request: request("fast", "pending", installed.branch) }, updater: { ...updater, state: "updating..." } }))
|
|
fast.feed.action("fast", installed.branch)
|
|
assert.equal(fast.requests.length, 2)
|
|
fast.fire(1000)
|
|
await fast.reply(2, snapshot({ operations: { ...operations, canFastUpdate: true, request: { ...request("fast", "complete", installed.branch), outcome: "up_to_date" } } }))
|
|
assert.equal(fast.feed.installBaseline, null)
|
|
assert.match(fast.feed.notice, /already up to date/)
|
|
fast.feed.stop()
|
|
|
|
const fastLost = fixture()
|
|
fastLost.feed.start()
|
|
await fastLost.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
fastLost.feed.action("fast", installed.branch)
|
|
await fastLost.fail(1)
|
|
assert.equal(fastLost.feed.uncertain, true)
|
|
fastLost.feed.action("fast", installed.branch)
|
|
assert.equal(fastLost.requests.length, 2)
|
|
fastLost.fire(2000)
|
|
await fastLost.reply(2, snapshot({ installed: { ...installed, commit: "b".repeat(40) }, operations: { ...operations, canFastUpdate: true } }))
|
|
assert.equal(fastLost.feed.installBaseline, null)
|
|
assert.equal(fastLost.feed.uncertain, false)
|
|
assert.match(fastLost.feed.notice, /verified after reconnect/)
|
|
fastLost.feed.stop()
|
|
|
|
const fastPage = { actionDisabled: false, operations: { canFastUpdate: true, selectedTarget: "beta" }, data: { installed }, dialog: null }
|
|
SoftwarePage.methods.askUpdate.call(fastPage)
|
|
assert.deepEqual(fastPage.dialog, { action: "update", branch: "beta", title: "Update software", message: "Install the latest version of beta and restart?", label: "Update & restart" })
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canFastUpdate: "yes" } })), false)
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, request: { ...request("fast"), outcome: "invented" } } })), false)
|
|
|
|
const rollbackPage = { actionDisabled: false, operations: { canRollback: true }, data: { installed }, dialog: null }
|
|
SoftwarePage.methods.askRollback.call(rollbackPage)
|
|
assert.equal(rollbackPage.dialog.action, "rollback")
|
|
assert.equal(rollbackPage.dialog.branch, installed.branch)
|
|
assert.equal(rollbackPage.dialog.title, "Previous version")
|
|
assert.match(rollbackPage.dialog.message, /Automatic downloads will be turned off/)
|
|
const unavailableRollback = { ...rollbackPage, operations: { canRollback: false }, dialog: null }
|
|
SoftwarePage.methods.askRollback.call(unavailableRollback)
|
|
assert.equal(unavailableRollback.dialog, null)
|
|
assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canRollback: "yes" } })), false)
|
|
|
|
|
|
// Recent-version selection pins both the installed revision and listed target.
|
|
{
|
|
const h = fixture()
|
|
h.feed.start()
|
|
const selected = "b".repeat(40)
|
|
const history = { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
|
|
recent: { branch: "Dom", head: "c".repeat(40), entries: [{ hash: selected, date: "2026-10-05", subject: "Older build" }] } }
|
|
await h.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true, history } }))
|
|
assert.equal(h.feed.action("version", "Dom", { expectedCommit: "f".repeat(40), selectedCommit: selected }), null)
|
|
assert.equal(h.feed.action("version", "Dom", { expectedCommit: installed.commit, selectedCommit: "f".repeat(40) }), null)
|
|
const pending = h.feed.action("version", "Dom", { expectedCommit: installed.commit, selectedCommit: selected })
|
|
assert.deepEqual(JSON.parse(h.requests[1].options.body), { action: "version", branch: "Dom", expectedCommit: installed.commit, selectedCommit: selected })
|
|
await h.reply(1, snapshot({ operations: { ...operations, canFastUpdate: false, history,
|
|
request: { ...request("version", "pending", "Dom"), selectedCommit: selected } } }))
|
|
await pending
|
|
assert.equal(h.feed.installBaseline.selectedCommit, selected)
|
|
h.fire(1000)
|
|
await h.reply(2, snapshot({ operations: { ...operations, history,
|
|
request: { ...request("version", "complete", "Dom"), selectedCommit: "c".repeat(40), outcome: "up_to_date" } } }))
|
|
assert.equal(h.feed.installBaseline.selectedCommit, selected)
|
|
h.fire(1000)
|
|
await h.reply(3, snapshot({ installed: { ...installed, commit: "c".repeat(40) }, operations: { ...operations, history } }))
|
|
assert.equal(h.feed.installBaseline.selectedCommit, selected)
|
|
assert.notEqual(h.feed.notice, "Installed build verified after reconnect.")
|
|
h.fire(1000)
|
|
await h.reply(4, snapshot({ installed: { ...installed, commit: selected }, operations: { ...operations, history } }))
|
|
assert.equal(h.feed.installBaseline, null)
|
|
assert.equal(h.feed.notice, "Installed build verified after reconnect.")
|
|
h.feed.stop()
|
|
}
|
|
|
|
// Real stage progress remains indeterminate until measured numeric progress exists.
|
|
assert.equal(softwareProgress(updater), null)
|
|
assert.equal(softwareProgress(updater, {progress: {stage: null, detail: "", percent: null}}), null)
|
|
assert.deepEqual(softwareProgress({...updater, state: "finalizing update..."}),
|
|
{active: true, failed: false, percent: null, label: "Finalizing update", detail: ""})
|
|
assert.equal(softwareProgress({...updater, state: "downloading..."}, {progress: {stage: "Downloading", detail: "", percent: 37}}).percent, 37)
|
|
for (const percent of [NaN, Infinity, -1, 101]) {
|
|
assert.equal(validSoftwareSnapshot(snapshot({operations: {...operations, progress: {stage: "fetching", detail: "", percent}}})), false)
|
|
}
|
|
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "unavailable", detail: "Updater status unavailable", percent: null}}).active, false)
|
|
assert.ok(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: null}})))
|
|
assert.equal(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: 12}})), false)
|
|
assert.match(SoftwarePage.template, /<summary>Advanced options<\/summary>/)
|
|
assert.match(SoftwarePage.template, /<summary>Versions & release notes<\/summary>/)
|
|
assert.match(SoftwarePage.template, /data\.updater\.lastCheckedAt/)
|
|
|
|
for (const progress of [{stage: "idle", detail: "", percent: null}, {stage: null, detail: null, percent: null}, {stage: "unknown", detail: "", percent: null}]) {
|
|
assert.ok(validSoftwareSnapshot(snapshot({operations: {...operations, progress}})))
|
|
assert.equal(softwareProgress(updater, {progress}), null)
|
|
}
|
|
for (const stage of ["complete", "error"]) {
|
|
const state = {...updater, state: "updating...", fast: {stage, detail: "Finished"}}
|
|
const progress = {stage, detail: "Finished", percent: null}
|
|
assert.equal(softwareProgress(state, {progress}).active, false)
|
|
assert.equal(softwareProgress(state, {progress}).percent, null)
|
|
}
|
|
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Finalizing update")
|
|
|
|
const lightweight = fixture(false)
|
|
lightweight.feed.start()
|
|
assert.equal(lightweight.requests[0].url, './api/software/status?history=0')
|
|
await lightweight.reply(0, snapshot())
|
|
assert.equal(lightweight.states.at(-1).busy, false)
|
|
assert.equal(lightweight.states.at(-1).data.operations.canCheck, true)
|
|
SoftwarePage.methods.loadHistory.call({ operations, busy: false, feed: lightweight.feed }, { target: { open: true } })
|
|
assert.equal(lightweight.requests[1].url, './api/software/status')
|
|
await lightweight.reply(1, snapshot({ operations: { ...operations, history } }))
|
|
assert.deepEqual(lightweight.states.at(-1).data.operations.history, history)
|
|
lightweight.feed.stop()
|
|
|
|
const directBranch = fixture()
|
|
directBranch.feed.start()
|
|
await directBranch.reply(0,snapshot({operations:{...operations,availableBranches:['Dom','beta'],canFastUpdate:true}}))
|
|
const branchVm={actionDisabled:false,draftBranch:'beta',operations:directBranch.states.at(-1).data.operations,data:{installed},dialog:null,feed:directBranch.feed}
|
|
SoftwarePage.methods.askUpdate.call(branchVm)
|
|
assert.equal(branchVm.dialog.label,'Update & restart')
|
|
const acceptedSwitch=SoftwarePage.methods.confirmDialog.call(branchVm)
|
|
assert.deepEqual(JSON.parse(directBranch.requests[1].options.body),{action:'update',branch:'beta'})
|
|
assert.equal(directBranch.requests.length,2,'switching uses one request and one confirmation')
|
|
await directBranch.reply(1,snapshot())
|
|
await acceptedSwitch
|
|
directBranch.feed.stop()
|
|
|
|
// A visit checks immediately, without interrupting an update already in progress.
|
|
for (const [canCheck, pending, state, expected] of [
|
|
[true, false, 'idle', ['check']], [false, false, 'idle', []],
|
|
[true, true, 'idle', []], [true, false, 'downloading...', []],
|
|
]) {
|
|
const actions = []
|
|
const view = { mode: 'local', initialChecking: true, operations: { canCheck }, pending,
|
|
data: { updater: { state } }, feed: { active: true, start: async () => {}, action: async action => actions.push(action) } }
|
|
await SoftwarePage.mounted.call(view)
|
|
assert.deepEqual(actions, expected)
|
|
assert.equal(view.initialChecking, false)
|
|
}
|
|
|
|
const updateView = { ...SoftwarePage.data(), data: snapshot(), operations: { ...operations },
|
|
requestMessage: SoftwarePage.methods.requestMessage }
|
|
for (const key of ['pending', 'reportedProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) {
|
|
Object.defineProperty(updateView, key, { get: () => SoftwarePage.computed[key].call(updateView) })
|
|
}
|
|
assert.equal(updateView.updateProgress.label, 'Checking for updates')
|
|
assert.equal(updateView.updateBanner, null)
|
|
updateView.initialChecking = false
|
|
assert.deepEqual(updateView.updateBanner, { tone: 'success', message: 'Update Available' })
|
|
updateView.data.updater.osUpdate = { branch: updateView.operations.selectedTarget, required: true }
|
|
assert.equal(updateView.updateBanner.tone, 'warn')
|
|
assert.match(updateView.updateBanner.message, /operating system update required/)
|
|
updateView.data.updater.osUpdate.branch = 'another-branch'
|
|
assert.equal(updateView.updateBanner.tone, 'success')
|
|
updateView.data.updater.osUpdate = { branch: updateView.operations.selectedTarget, required: false }
|
|
assert.equal(updateView.updateBanner.tone, 'success')
|
|
assert.ok(!SoftwarePage.template.includes('View commits'))
|
|
|
|
updateView.data = snapshot({ updater: { ...updater, targetChangeFound: false } })
|
|
updateView.operations.request = request('check', 'complete')
|
|
assert.equal(updateView.updateBanner.message, 'No Update Available')
|
|
updateView.operations.request = request('check')
|
|
assert.equal(updateView.updateProgress.label, 'Checking for updates')
|
|
assert.equal(updateView.updateBanner, null)
|
|
updateView.operations.request = request('check', 'failed', null, 'Network unavailable')
|
|
assert.equal(updateView.updateProgress, null)
|
|
assert.deepEqual(updateView.updateBanner, { tone: 'danger', message: 'Network unavailable' })
|
|
updateView.error = 'Connection lost'
|
|
assert.equal(updateView.updateBanner.message, 'Connection lost')
|
|
updateView.operations.repository = 'firestar5683/openpilot'
|
|
updateView.draftBranch = 'beta'
|
|
assert.equal(updateView.commitUrl, `https://github.com/firestar5683/openpilot/commit/${installed.commit}`)
|
|
assert.equal(updateView.commitsUrl, 'https://github.com/StarPilotHQ/StarPilot/commits/Dom/')
|
|
assert.equal(SoftwarePage.computed.commitsUrl.call({ data: { installed: { branch: 'SecretGoodStarPilot' } } }),
|
|
'https://github.com/StarPilotHQ/StarPilot/commits/SecretGoodStarPilot/')
|
|
|
|
// The same button installs a prepared build or requests the updater-owned automatic path.
|
|
const ready = { actionDisabled: false, draftBranch: "Dom", operations: { canInstall: true, selectedTarget: "Dom" },
|
|
data: snapshot({ updater: { ...updater, finalizedUpdateReady: true } }), askInstall() { this.installed = true } }
|
|
assert.equal(SoftwarePage.computed.canUpdate.call(ready), true)
|
|
SoftwarePage.methods.askUpdate.call(ready)
|
|
assert.equal(ready.installed, true)
|
|
const automatic = fixture()
|
|
automatic.feed.start()
|
|
await automatic.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
automatic.feed.action("update", installed.branch)
|
|
assert.deepEqual(JSON.parse(automatic.requests[1].options.body), { action: "update", branch: installed.branch })
|
|
await automatic.reply(1, snapshot({ updater: { ...updater, state: "updating..." },
|
|
operations: { ...operations, canFastUpdate: false, request: request("update", "pending", installed.branch) } }))
|
|
assert.equal(automatic.feed.installBaseline.action, "update")
|
|
automatic.fire(1000)
|
|
await automatic.reply(2, snapshot({ operations: { ...operations, request: { ...request("update", "complete", installed.branch), outcome: "up_to_date" } } }))
|
|
assert.equal(automatic.feed.installBaseline, null)
|
|
automatic.feed.stop()
|
|
|
|
// A remote tunnel can answer while the comma itself is restarting.
|
|
for (const [code, body] of [[502, null], [504, { message: 'Device unreachable' }],
|
|
[503, { message: 'Your comma cannot be reached' }],
|
|
[503, { error: 'Device offline' }], [200, null]]) {
|
|
const restarts = []
|
|
const f = fixture(true, value => restarts.push(value))
|
|
f.feed.start()
|
|
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
f.feed.action('update', 'Dom')
|
|
await f.reply(1, body, code)
|
|
assert.ok(restarts.length > 0, `update disconnect ${code} must show restart recovery`)
|
|
assert.equal(restarts.at(-1).action, 'update')
|
|
assert.equal(f.feed.uncertain, true)
|
|
f.feed.stop()
|
|
const ordinary = fixture(true, () => { throw new Error('ordinary disconnect is not an update') })
|
|
ordinary.feed.start()
|
|
await ordinary.reply(0, body, code)
|
|
ordinary.feed.stop()
|
|
}
|
|
for (const [state, outcome] of [['failed', undefined], ['complete', 'up_to_date']]) {
|
|
let canceled = 0
|
|
const f = fixture(true, () => { throw new Error('finished update must not imply restart') }, () => {}, () => canceled++)
|
|
f.feed.start()
|
|
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
f.feed.action('update', 'Dom')
|
|
await f.reply(1, snapshot({ operations: { ...operations, request: request('update', 'pending', 'Dom') } }))
|
|
f.feed.load()
|
|
await f.reply(2, snapshot({ operations: { ...operations, request: { ...request('update', state, 'Dom'), outcome } } }))
|
|
assert.equal(f.feed.installBaseline, null)
|
|
assert.equal(canceled, 1)
|
|
f.feed.stop()
|
|
}
|
|
|
|
// Gateway error envelopes are not software snapshots, even when the tunnel uses HTTP 200.
|
|
const unreachableMessage = 'Your comma cannot be reached. Check your connection and try again'
|
|
for (const action of ['update', 'fast', 'rollback', 'install', 'version']) {
|
|
for (const status of [200, 400, 500, 503]) {
|
|
for (const field of ['error', 'message']) {
|
|
for (const duringPoll of [false, true]) {
|
|
const restarts = []
|
|
const f = fixture(true, baseline => restarts.push(baseline))
|
|
f.feed.start()
|
|
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true, canRollback: true, canInstall: true,
|
|
history: { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
|
|
recent: { branch: 'Dom', head: installed.commit, entries: [{ hash: 'b'.repeat(40), date: '', subject: '' }] } } } }))
|
|
f.feed.action(action, 'Dom', { expectedCommit: installed.commit, selectedCommit: 'b'.repeat(40) })
|
|
assert.equal(f.requests.length, 2)
|
|
if (duringPoll) {
|
|
await f.reply(1, snapshot({ operations: { ...operations, request: request(action, 'pending', 'Dom') } }))
|
|
f.fire(1000)
|
|
}
|
|
await f.reply(duringPoll ? 2 : 1, { [field]: unreachableMessage }, status)
|
|
assert.ok(restarts.length, `${action} ${status} ${field} ${duringPoll ? 'poll' : 'POST'} must reconnect`)
|
|
assert.equal(restarts.at(-1).action, action)
|
|
assert.equal(f.feed.installBaseline.action, action)
|
|
if (!duringPoll) assert.equal(f.feed.uncertain, true)
|
|
f.feed.stop()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// The same envelope outside a user-started update must remain an ordinary error.
|
|
for (const status of [200, 400, 500]) {
|
|
const f = fixture(true, () => assert.fail('ordinary status failure must not show update recovery'))
|
|
f.feed.start()
|
|
await f.reply(0, { message: unreachableMessage }, status)
|
|
assert.equal(f.states.at(-1).error, unreachableMessage)
|
|
f.feed.stop()
|
|
}
|
|
// Structured service and authorization errors keep their existing behavior.
|
|
for (const [status, payload, unauthorized] of [
|
|
[401, { message: unreachableMessage }, true],
|
|
[503, { code: 'access_unavailable', message: unreachableMessage }, true],
|
|
[503, { code: 'setup_required', message: unreachableMessage }, true],
|
|
[500, { code: 'update_failed', error: 'Failed to apply update' }, false],
|
|
[503, { error: 'Updater status is unavailable' }, false],
|
|
[409, { error: 'Update rejected' }, false],
|
|
]) {
|
|
const f = fixture(true, () => assert.fail('structured/auth error must not show update recovery'))
|
|
f.feed.start()
|
|
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true } }))
|
|
f.feed.action('update', 'Dom')
|
|
await f.reply(1, payload, status)
|
|
assert.equal(f.unauthorized, Number(unauthorized))
|
|
if (!unauthorized) assert.equal(f.states.at(-1).error, payload.error)
|
|
f.feed.stop()
|
|
}
|
|
console.log('Software status and tunnel-envelope regressions passed')
|