mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
328 lines
15 KiB
TypeScript
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'
|
|
])
|
|
})
|
|
})
|