Files
orca/src/shared/release-channel.test.ts

328 lines
15 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
findInstallerAssetName,
formatAdhocVersion,
formatDailyVersion,
formatHourlyVersion,
getReleaseNotesUrlForVersion,
getReleaseRepoForChannel,
getVersionChannel,
hasDedicatedReleaseRepo,
hasInstallableArtifactForPlatform,
isAdhocVersion,
isChannelSupportedOnPlatform,
isDailyVersion,
isHourlyVersion,
isReleaseChannel,
parseAdhocVersionStamp,
parseDailyVersionStamp,
parseDevBuildStamp,
parseHourlyVersionStamp,
requiresManualDevChannelInstall,
sortReleaseBuildsNewestFirst,
type ReleaseBuild,
type ReleaseChannel
} from './release-channel'
import { compareAppVersions } from './app-version'
describe('release channel', () => {
it('classifies versions by channel', () => {
expect(getVersionChannel('1.4.160')).toBe('stable')
expect(getVersionChannel('v1.4.160')).toBe('stable')
expect(getVersionChannel('1.4.160-rc.3')).toBe('rc')
expect(getVersionChannel('1.4.160-hourly.202607281400')).toBe('hourly')
expect(getVersionChannel('1.4.160-daily.202607281300')).toBe('daily')
expect(getVersionChannel('1.4.160-adhoc.20260728140533')).toBe('adhoc')
expect(getVersionChannel('not-a-version')).toBeNull()
})
// Why: hourly tags must never resolve to the main repo — the releases atom feed
// exposes only 10 entries, so 24 hourly tags a day would evict every stable/RC
// entry and leave real users with nothing to update to.
it('keeps dev builds out of the main release repo, and apart from each other', () => {
expect(getReleaseRepoForChannel('hourly')).toBe('stablyai/orca-hourly')
expect(getReleaseRepoForChannel('daily')).toBe('stablyai/orca-daily')
// Why adhoc gets its own repo rather than sharing hourly's: an unlanded
// branch build must never surface to someone who only meant to ride main.
expect(getReleaseRepoForChannel('adhoc')).toBe('stablyai/orca-adhoc')
expect(getReleaseRepoForChannel('stable')).toBe('stablyai/orca')
expect(getReleaseRepoForChannel('rc')).toBe('stablyai/orca')
})
it('marks exactly the dev channels as having their own repo', () => {
expect(hasDedicatedReleaseRepo('hourly')).toBe(true)
expect(hasDedicatedReleaseRepo('daily')).toBe(true)
expect(hasDedicatedReleaseRepo('adhoc')).toBe(true)
expect(hasDedicatedReleaseRepo('stable')).toBe(false)
expect(hasDedicatedReleaseRepo('rc')).toBe(false)
})
// Why: an hourly tag linked against the main repo 404s — the tag only exists
// in the hourly repo.
it('builds release-notes links against the repo that published the version', () => {
expect(getReleaseNotesUrlForVersion('1.4.160-hourly.202607281400')).toBe(
'https://github.com/stablyai/orca-hourly/releases/tag/v1.4.160-hourly.202607281400'
)
expect(getReleaseNotesUrlForVersion('1.4.160-daily.202607281300')).toBe(
'https://github.com/stablyai/orca-daily/releases/tag/v1.4.160-daily.202607281300'
)
expect(getReleaseNotesUrlForVersion('1.4.160')).toBe(
'https://github.com/stablyai/orca/releases/tag/v1.4.160'
)
expect(getReleaseNotesUrlForVersion('v1.4.160-rc.3')).toBe(
'https://github.com/stablyai/orca/releases/tag/v1.4.160-rc.3'
)
expect(getReleaseNotesUrlForVersion('1.4.160-adhoc.20260728140533')).toBe(
'https://github.com/stablyai/orca-adhoc/releases/tag/v1.4.160-adhoc.20260728140533'
)
expect(getReleaseNotesUrlForVersion(null)).toBe('https://github.com/stablyai/orca/releases')
})
it('round-trips an hourly version stamp as UTC', () => {
const version = formatHourlyVersion('1.4.160', '202607281405')
expect(isHourlyVersion(version)).toBe(true)
expect(parseHourlyVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:00.000Z')
})
it('round-trips a daily version stamp as UTC', () => {
const version = formatDailyVersion('1.4.160', '202607281300')
expect(isDailyVersion(version)).toBe(true)
expect(parseDailyVersionStamp(version)?.toISOString()).toBe('2026-07-28T13:00:00.000Z')
})
it('rejects malformed hourly identifiers', () => {
expect(isHourlyVersion('1.4.160-hourly')).toBe(false)
expect(isHourlyVersion('1.4.160-hourly.2026')).toBe(false)
expect(isHourlyVersion('1.4.160-rc.3')).toBe(false)
expect(parseHourlyVersionStamp('1.4.160-rc.3')).toBeNull()
})
// Why: an unanchored tail match also accepted garbage prefixes, and Date.UTC
// rolls impossible dates forward, so `...hourly.202602300000` rendered as
// March 2 rather than being rejected.
it('rejects a bad base version and impossible calendar stamps', () => {
expect(parseHourlyVersionStamp('not-a-version-hourly.202601010000')).toBeNull()
expect(parseHourlyVersionStamp('1.4-hourly.202601010000')).toBeNull()
expect(parseHourlyVersionStamp('1.4.160-hourly.202602300000')).toBeNull()
expect(parseHourlyVersionStamp('1.4.160-hourly.202613010000')).toBeNull()
expect(parseHourlyVersionStamp('1.4.160-hourly.202601012500')).toBeNull()
// Leap day 2028 is real and must still parse.
expect(parseHourlyVersionStamp('1.4.160-hourly.202802290000')?.toISOString()).toBe(
'2028-02-29T00:00:00.000Z'
)
expect(parseDailyVersionStamp('1.4.160-daily.202602300000')).toBeNull()
expect(parseDailyVersionStamp('not-a-version-daily.202601010000')).toBeNull()
})
// Why seconds and not hourly's minutes: adhoc builds are dispatched on demand,
// so two people cutting from different branches inside the same minute is
// ordinary — at minute resolution the second would collide on the tag.
it('round-trips an adhoc version stamp as UTC, to the second', () => {
const version = formatAdhocVersion('1.4.160', '20260728140533')
expect(isAdhocVersion(version)).toBe(true)
expect(parseAdhocVersionStamp(version)?.toISOString()).toBe('2026-07-28T14:05:33.000Z')
})
it('keeps the dev stamp formats from matching each other', () => {
expect(isAdhocVersion('1.4.160-hourly.202607281400')).toBe(false)
expect(isHourlyVersion('1.4.160-adhoc.20260728140533')).toBe(false)
expect(isDailyVersion('1.4.160-hourly.202607281400')).toBe(false)
expect(isHourlyVersion('1.4.160-daily.202607281300')).toBe(false)
expect(isDailyVersion('1.4.160-adhoc.20260728140533')).toBe(false)
// A 12-digit adhoc tail is an hourly stamp wearing the wrong identifier, not
// a second-resolution one; rejecting it keeps the parse unambiguous.
expect(isAdhocVersion('1.4.160-adhoc.202607281405')).toBe(false)
})
it('rejects impossible adhoc calendar stamps, including the seconds field', () => {
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260230000000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20261301000000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260101250000')).toBeNull()
expect(parseAdhocVersionStamp('1.4.160-adhoc.20260101000060')).toBeNull()
expect(parseAdhocVersionStamp('not-a-version-adhoc.20260101000000')).toBeNull()
})
// Why one entry point for all: the picker renders a row without knowing which
// dev channel produced it, so a channel added without a case here would fall
// back to showing its raw opaque timestamp tail.
it('reads the build timestamp of any dev channel', () => {
expect(parseDevBuildStamp('1.4.160-hourly.202607281405')?.toISOString()).toBe(
'2026-07-28T14:05:00.000Z'
)
expect(parseDevBuildStamp('1.4.160-daily.202607281300')?.toISOString()).toBe(
'2026-07-28T13:00:00.000Z'
)
expect(parseDevBuildStamp('1.4.160-adhoc.20260728140533')?.toISOString()).toBe(
'2026-07-28T14:05:33.000Z'
)
expect(parseDevBuildStamp('1.4.160-rc.3')).toBeNull()
expect(parseDevBuildStamp('1.4.160')).toBeNull()
})
// Why: the dev workflows build macOS and Windows but not Linux, so a Linux
// install has no artifact to offer. Both the picker and the main-process check
// read this, so a regression here would silently expose an uninstallable
// channel.
it('offers the dev channels on macOS and Windows but not Linux', () => {
for (const channel of ['hourly', 'daily', 'adhoc'] as const) {
expect(isChannelSupportedOnPlatform(channel, 'darwin')).toBe(true)
expect(isChannelSupportedOnPlatform(channel, 'win32')).toBe(true)
expect(isChannelSupportedOnPlatform(channel, 'linux')).toBe(false)
}
})
// The whole Windows story in one test. electron-updater verifies a downloaded
// installer against the publisherName baked into the *installed* app, so a
// signed stable/RC rejects an unsigned dev installer and no future build can
// fix the copies already out there. Dev builds carry no publisherName, so
// everything leaving a dev channel — including the way back to stable — works.
it('requires a manual install only when entering a dev channel from a signed Windows build', () => {
const manual = (runningChannel: ReleaseChannel | null, targetChannel: ReleaseChannel) =>
requiresManualDevChannelInstall({ platform: 'win32', runningChannel, targetChannel })
expect(manual('stable', 'adhoc')).toBe(true)
expect(manual('rc', 'hourly')).toBe(true)
expect(manual('stable', 'daily')).toBe(true)
// Unparseable version: assume signed, which sends the user to a download
// that works rather than an update that fails on a signature error.
expect(manual(null, 'adhoc')).toBe(true)
// Already unsigned — the updater skips verification entirely from here.
expect(manual('adhoc', 'hourly')).toBe(false)
expect(manual('hourly', 'adhoc')).toBe(false)
expect(manual('hourly', 'stable')).toBe(false)
expect(manual('adhoc', 'rc')).toBe(false)
// Not a dev channel at all.
expect(manual('stable', 'rc')).toBe(false)
expect(manual('rc', 'stable')).toBe(false)
})
// Why: macOS dev builds are signed and notarized like a release, so the
// updater installs them over a stable build with no manual step.
it('never requires a manual install off Windows', () => {
for (const platform of ['darwin', 'linux'] as const) {
expect(
requiresManualDevChannelInstall({
platform,
runningChannel: 'stable',
targetChannel: 'adhoc'
})
).toBe(false)
}
})
it('detects an installable artifact from the platform update manifest', () => {
expect(hasInstallableArtifactForPlatform('win32', ['latest.yml'])).toBe(true)
expect(hasInstallableArtifactForPlatform('win32', ['latest-mac.yml'])).toBe(false)
expect(hasInstallableArtifactForPlatform('darwin', ['latest-mac.yml'])).toBe(true)
expect(hasInstallableArtifactForPlatform('darwin', ['latest.yml'])).toBe(false)
expect(hasInstallableArtifactForPlatform('linux', ['latest-linux-arm64.yml'])).toBe(true)
expect(hasInstallableArtifactForPlatform('linux', [])).toBe(false)
// An unknown platform must not hide every build; a download-time error is a
// better failure than an empty picker with no explanation.
expect(hasInstallableArtifactForPlatform('freebsd', [])).toBe(true)
})
it('finds the directly runnable installer for a platform', () => {
const assets = [
'latest.yml',
'orca-windows-setup.exe',
'orca-macos-arm64.dmg',
'orca-linux.AppImage'
]
expect(findInstallerAssetName('win32', assets)).toBe('orca-windows-setup.exe')
expect(findInstallerAssetName('darwin', assets)).toBe('orca-macos-arm64.dmg')
expect(findInstallerAssetName('linux', assets)).toBe('orca-linux.AppImage')
expect(findInstallerAssetName('win32', ['latest.yml'])).toBeNull()
expect(findInstallerAssetName('freebsd', assets)).toBeNull()
})
it('offers stable and rc on every platform', () => {
for (const platform of ['darwin', 'linux', 'win32'] as const) {
expect(isChannelSupportedOnPlatform('stable', platform)).toBe(true)
expect(isChannelSupportedOnPlatform('rc', platform)).toBe(true)
}
})
it('accepts only known channels', () => {
expect(isReleaseChannel('hourly')).toBe(true)
expect(isReleaseChannel('daily')).toBe(true)
expect(isReleaseChannel('adhoc')).toBe(true)
expect(isReleaseChannel('stable')).toBe(true)
expect(isReleaseChannel('nightly')).toBe(false)
expect(isReleaseChannel(null)).toBe(false)
expect(isReleaseChannel(undefined)).toBe(false)
})
// Why: consecutive hourlies differ only in the timestamp tail, so semver
// ordering must follow the clock or the picker offers them out of order.
it('sorts consecutive hourly builds newest first', () => {
const build = (version: string): ReleaseBuild => ({
tag: `v${version}`,
version,
channel: 'hourly',
name: null,
publishedAt: null,
releaseUrl: `https://github.com/stablyai/orca-hourly/releases/tag/v${version}`,
installerUrl: null
})
const sorted = sortReleaseBuildsNewestFirst([
build('1.4.160-hourly.202607280900'),
build('1.4.160-hourly.202607281400'),
build('1.4.160-hourly.202607281000')
])
expect(sorted.map((entry) => entry.version)).toEqual([
'1.4.160-hourly.202607281400',
'1.4.160-hourly.202607281000',
'1.4.160-hourly.202607280900'
])
})
// Why: an hourly is cut from main and must not read as newer than the stable it
// is based on, or stable users would be offered it by an ordinary check.
it('orders an hourly below its own stable release', () => {
expect(compareAppVersions('1.4.160-hourly.202607281400', '1.4.160')).toBeLessThan(0)
})
it('orders a daily below its own stable release and below hourly of the same base', () => {
expect(compareAppVersions('1.4.160-daily.202607281300', '1.4.160')).toBeLessThan(0)
expect(
compareAppVersions('1.4.160-daily.202607281300', '1.4.160-hourly.202607281400')
).toBeLessThan(0)
})
// Why adhoc sits at the very bottom: it is an unlanded branch, the least
// trustworthy thing the updater can hand anyone. Every other channel of the
// same base version must outrank it so no routine check ever selects one.
it('orders an adhoc build below every other channel of its base version', () => {
const adhoc = '1.4.160-adhoc.20260728140533'
expect(compareAppVersions(adhoc, '1.4.160')).toBeLessThan(0)
expect(compareAppVersions(adhoc, '1.4.160-rc.1')).toBeLessThan(0)
expect(compareAppVersions(adhoc, '1.4.160-hourly.202607280000')).toBeLessThan(0)
expect(compareAppVersions(adhoc, '1.4.160-daily.202607281300')).toBeLessThan(0)
})
it('sorts consecutive adhoc builds newest first', () => {
const build = (version: string): ReleaseBuild => ({
tag: `v${version}`,
version,
channel: 'adhoc',
name: null,
publishedAt: null,
releaseUrl: `https://github.com/stablyai/orca-adhoc/releases/tag/v${version}`,
installerUrl: null
})
const sorted = sortReleaseBuildsNewestFirst([
build('1.4.160-adhoc.20260728140502'),
build('1.4.160-adhoc.20260728140541'),
build('1.4.160-adhoc.20260728090000')
])
expect(sorted.map((entry) => entry.version)).toEqual([
'1.4.160-adhoc.20260728140541',
'1.4.160-adhoc.20260728140502',
'1.4.160-adhoc.20260728090000'
])
})
})