@skyhook-io/radar-app 1.14.6 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/App.tsx +51 -6
- package/src/api/client.admission.test.ts +34 -0
- package/src/api/client.ts +37 -3
- package/src/api/usage-data.fixtures.ts +33 -0
- package/src/api/usage-data.ts +165 -0
- package/src/components/CloudFunnelButton.test.tsx +2 -2
- package/src/components/CloudFunnelButton.tsx +99 -55
- package/src/components/SelfManagedStart.tsx +96 -0
- package/src/components/cloudConnectHandoff.test.ts +1 -1
- package/src/components/cloudConnectHandoff.ts +1 -1
- package/src/components/diagnose/ActivityTurn.tsx +3 -2
- package/src/components/diagnose/ApplyDialog.tsx +4 -2
- package/src/components/dialog-names.test.ts +69 -0
- package/src/components/execution/BatchExecutionView.tsx +1 -1
- package/src/components/execution/JobSetAdmission.test.tsx +160 -37
- package/src/components/execution/JobSetAdmission.tsx +16 -7
- package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
- package/src/components/home/HomeView.tsx +6 -2
- package/src/components/home/RadarVersionLine.test.tsx +14 -0
- package/src/components/home/RadarVersionLine.tsx +28 -3
- package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
- package/src/components/nav/PrimaryNavRail.tsx +25 -3
- package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
- package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
- package/src/components/settings/PrivacySection.test.tsx +84 -0
- package/src/components/settings/PrivacySection.tsx +140 -0
- package/src/components/settings/SettingsDialog.tsx +13 -52
- package/src/components/settings/controls.tsx +55 -0
- package/src/components/settings/settings-state.ts +1 -0
- package/src/components/ui/ErrorBoundary.tsx +5 -0
- package/src/components/ui/Omnibar.tsx +16 -0
- package/src/components/ui/UpdateNotification.tsx +13 -9
- package/src/components/ui/command-items.ts +14 -0
- package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
- package/src/components/usage-data/UsageDataAsk.tsx +106 -0
- package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
- package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
- package/src/components/whats-new/WhatsNew.test.ts +110 -0
- package/src/components/whats-new/WhatsNew.tsx +423 -0
- package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
- package/src/components/whats-new/check-whats-new.test.ts +65 -0
- package/src/components/whats-new/releaseNotes.ts +121 -0
- package/src/components/workload/WorkloadView.tsx +11 -1
- package/src/k8s-ui-exports.test.ts +73 -0
- package/src/utils/navigation.test.ts +17 -1
- package/src/utils/navigation.ts +16 -0
- package/src/utils/version.ts +11 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { BarChart3, Check } from 'lucide-react'
|
|
3
|
+
import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
4
|
+
|
|
5
|
+
// One wording for every place Radar asks, so the question can't drift.
|
|
6
|
+
export function UsageDataBlurb({ onReadMore }: { onReadMore: () => void }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<p className="text-sm font-medium text-theme-text-primary">Help improve Radar</p>
|
|
10
|
+
<p className="mt-0.5 text-xs text-theme-text-secondary leading-relaxed">
|
|
11
|
+
Anonymous, with no third-party trackers.{' '}
|
|
12
|
+
<button
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={onReadMore}
|
|
15
|
+
className="whitespace-nowrap text-theme-text-primary underline underline-offset-2 hover:text-accent-text"
|
|
16
|
+
>
|
|
17
|
+
See what's sent
|
|
18
|
+
</button>
|
|
19
|
+
</p>
|
|
20
|
+
</>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Shown in place of the question once answered, so it doesn't vanish under
|
|
25
|
+
// the cursor.
|
|
26
|
+
export function UsageDataAnswered({ answer }: { answer: boolean }) {
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Check className="w-3.5 h-3.5 shrink-0 text-accent" aria-hidden />
|
|
30
|
+
{answer
|
|
31
|
+
? 'Thanks. Change this any time in Settings > Privacy.'
|
|
32
|
+
: 'Nothing will be sent. Change this any time in Settings > Privacy.'}
|
|
33
|
+
</>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The usage-data question, asked inside What's New when the server says to:
|
|
38
|
+
// someone who closed it without answering is asked again only months later,
|
|
39
|
+
// and a "no" is never asked again. A shared Radar, or one set by its
|
|
40
|
+
// configuration, never asks.
|
|
41
|
+
export function UsageDataAsk({ usageData, onReadMore }: {
|
|
42
|
+
usageData: UsageDataStatus | undefined
|
|
43
|
+
onReadMore: () => void
|
|
44
|
+
}) {
|
|
45
|
+
const setUsageData = useSetUsageData()
|
|
46
|
+
const markUsagePromptShown = useMarkUsagePromptShown()
|
|
47
|
+
const [answer, setAnswer] = useState<boolean | null>(null)
|
|
48
|
+
// Latched: recording the showing stops the server offering the question,
|
|
49
|
+
// and the block must not vanish while the dialog is open.
|
|
50
|
+
const [offered, setOffered] = useState(!!usageData?.ask)
|
|
51
|
+
const recorded = useRef(false)
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (usageData?.ask) setOffered(true)
|
|
55
|
+
}, [usageData?.ask])
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (offered && !recorded.current) {
|
|
58
|
+
recorded.current = true
|
|
59
|
+
markUsagePromptShown()
|
|
60
|
+
}
|
|
61
|
+
}, [offered, markUsagePromptShown])
|
|
62
|
+
|
|
63
|
+
const undecided = offered && usageData?.state === 'undecided' && usageData.canChange
|
|
64
|
+
if (!undecided && answer === null) return null
|
|
65
|
+
|
|
66
|
+
if (answer !== null) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex items-center gap-2 px-6 py-3 border-t border-theme-border text-xs text-theme-text-secondary">
|
|
69
|
+
<UsageDataAnswered answer={answer} />
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex flex-col sm:flex-row sm:items-center gap-3 px-6 py-3.5 border-t border-theme-border">
|
|
78
|
+
<div className="flex gap-3 min-w-0 flex-1">
|
|
79
|
+
<span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
|
|
80
|
+
<BarChart3 className="w-4 h-4" aria-hidden />
|
|
81
|
+
</span>
|
|
82
|
+
<div className="min-w-0">
|
|
83
|
+
<UsageDataBlurb onReadMore={onReadMore} />
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex items-center gap-2 shrink-0 pl-11 sm:pl-0">
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
disabled={setUsageData.isPending}
|
|
90
|
+
onClick={() => choose(true)}
|
|
91
|
+
className="px-3 py-1.5 text-xs font-medium rounded-lg border border-theme-border bg-theme-surface text-theme-text-primary hover:bg-theme-hover disabled:opacity-50"
|
|
92
|
+
>
|
|
93
|
+
Send usage stats
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
disabled={setUsageData.isPending}
|
|
98
|
+
onClick={() => choose(false)}
|
|
99
|
+
className="px-3 py-1.5 text-xs font-medium rounded-lg text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
|
|
100
|
+
>
|
|
101
|
+
No thanks
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import type { UsageDataStatus } from '../../api/usage-data'
|
|
3
|
+
|
|
4
|
+
vi.mock('../../api/client', () => ({ useVersionCheck: () => ({ data: undefined }) }))
|
|
5
|
+
vi.mock('../../api/usage-data', () => ({ useSetUsageData: () => ({ mutate: vi.fn(), isPending: false }), useMarkUsagePromptShown: () => vi.fn() }))
|
|
6
|
+
|
|
7
|
+
import { cardStillAsking, shouldShowFirstRunPrompt, updateNoticeVisible } from './UsageDataPrompt'
|
|
8
|
+
|
|
9
|
+
const status = (firstRunPrompt: boolean) => ({ firstRunPrompt } as UsageDataStatus)
|
|
10
|
+
|
|
11
|
+
describe('shouldShowFirstRunPrompt', () => {
|
|
12
|
+
it('shows only when the server offers it, after the delay, with the corner free', () => {
|
|
13
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: false })).toBe(true)
|
|
14
|
+
expect(shouldShowFirstRunPrompt({ status: status(false), ready: true, updateNoticeShowing: false })).toBe(false)
|
|
15
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: false, updateNoticeShowing: false })).toBe(false)
|
|
16
|
+
expect(shouldShowFirstRunPrompt({ status: status(true), ready: true, updateNoticeShowing: true })).toBe(false)
|
|
17
|
+
expect(shouldShowFirstRunPrompt({ status: undefined, ready: true, updateNoticeShowing: false })).toBe(false)
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('updateNoticeVisible', () => {
|
|
22
|
+
const store = new Map<string, string>()
|
|
23
|
+
vi.stubGlobal('localStorage', {
|
|
24
|
+
getItem: (k: string) => store.get(k) ?? null,
|
|
25
|
+
setItem: (k: string, v: string) => void store.set(k, v),
|
|
26
|
+
})
|
|
27
|
+
afterEach(() => store.clear())
|
|
28
|
+
|
|
29
|
+
it('is visible until that version is dismissed', () => {
|
|
30
|
+
expect(updateNoticeVisible('1.16.0', true)).toBe(true)
|
|
31
|
+
store.set('radar-update-dismissed', '1.16.0')
|
|
32
|
+
expect(updateNoticeVisible('1.16.0', true)).toBe(false)
|
|
33
|
+
expect(updateNoticeVisible('1.16.0', false)).toBe(false)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('cardStillAsking', () => {
|
|
38
|
+
const st = (state: UsageDataStatus['state'], canChange = true) => ({ state, canChange } as UsageDataStatus)
|
|
39
|
+
|
|
40
|
+
it('keeps asking while the choice is still open', () => {
|
|
41
|
+
expect(cardStillAsking(st('undecided'), null)).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('stops asking once the choice was made elsewhere', () => {
|
|
45
|
+
expect(cardStillAsking(st('off'), null)).toBe(false)
|
|
46
|
+
expect(cardStillAsking(st('on'), null)).toBe(false)
|
|
47
|
+
expect(cardStillAsking(st('undecided', false), null)).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps its own answer on screen', () => {
|
|
51
|
+
expect(cardStillAsking(st('off'), false)).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import { BarChart3, X } from 'lucide-react'
|
|
4
|
+
import { useMarkUsagePromptShown, useSetUsageData, type UsageDataStatus } from '../../api/usage-data'
|
|
5
|
+
import { useVersionCheck } from '../../api/client'
|
|
6
|
+
import { isUpdateDismissed } from '../ui/UpdateNotification'
|
|
7
|
+
import { UsageDataAnswered, UsageDataBlurb } from './UsageDataAsk'
|
|
8
|
+
import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
|
|
9
|
+
import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
|
|
10
|
+
|
|
11
|
+
// Long enough that the first thing a new user sees is their cluster, not a
|
|
12
|
+
// question about it.
|
|
13
|
+
export const FIRST_RUN_PROMPT_DELAY_MS = 15_000
|
|
14
|
+
const THANKS_MS = 4_000
|
|
15
|
+
|
|
16
|
+
// The update notice uses the same corner. One nudge at a time.
|
|
17
|
+
export function updateNoticeVisible(latest: string | undefined, updateAvailable: boolean | undefined): boolean {
|
|
18
|
+
return !!updateAvailable && !!latest && !isUpdateDismissed(latest)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldShowFirstRunPrompt(opts: {
|
|
22
|
+
status: UsageDataStatus | undefined
|
|
23
|
+
ready: boolean
|
|
24
|
+
updateNoticeShowing: boolean
|
|
25
|
+
}): boolean {
|
|
26
|
+
return !!opts.status?.firstRunPrompt && opts.ready && !opts.updateNoticeShowing
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Once shown, the card stays until answered or closed, unless the choice was
|
|
30
|
+
// made elsewhere (another tab, Settings) while it sat unanswered.
|
|
31
|
+
export function cardStillAsking(status: UsageDataStatus | undefined, answer: boolean | null): boolean {
|
|
32
|
+
return answer !== null || (status?.state === 'undecided' && !!status?.canChange)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Shown once per machine, after the first install. Upgrading installs are
|
|
36
|
+
// asked in What's New instead. The server records the showing, so a reload,
|
|
37
|
+
// another browser or the Desktop app on the same machine won't show it again.
|
|
38
|
+
export function UsageDataPrompt({ status }: { status: UsageDataStatus | undefined }) {
|
|
39
|
+
const setUsageData = useSetUsageData()
|
|
40
|
+
const markUsagePromptShown = useMarkUsagePromptShown()
|
|
41
|
+
const { data: versionInfo } = useVersionCheck()
|
|
42
|
+
const [ready, setReady] = useState(false)
|
|
43
|
+
const [, setTick] = useState(0)
|
|
44
|
+
// Latched once shown: the server stops offering the prompt the moment it is
|
|
45
|
+
// recorded, and the card must not disappear because of that.
|
|
46
|
+
const [latched, setLatched] = useState(false)
|
|
47
|
+
const [dismissed, setDismissed] = useState(false)
|
|
48
|
+
const [answer, setAnswer] = useState<boolean | null>(null)
|
|
49
|
+
const recorded = useRef(false)
|
|
50
|
+
|
|
51
|
+
const offered = !!status?.firstRunPrompt
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!offered) return
|
|
54
|
+
const delay = window.setTimeout(() => setReady(true), FIRST_RUN_PROMPT_DELAY_MS)
|
|
55
|
+
// Dismissing the update notice only touches localStorage, which does not
|
|
56
|
+
// notify this tab, so look again now and then.
|
|
57
|
+
const poll = window.setInterval(() => setTick((t) => t + 1), 5_000)
|
|
58
|
+
return () => {
|
|
59
|
+
window.clearTimeout(delay)
|
|
60
|
+
window.clearInterval(poll)
|
|
61
|
+
}
|
|
62
|
+
}, [offered])
|
|
63
|
+
|
|
64
|
+
const eligible = shouldShowFirstRunPrompt({
|
|
65
|
+
status,
|
|
66
|
+
ready,
|
|
67
|
+
updateNoticeShowing: updateNoticeVisible(versionInfo?.latestVersion, versionInfo?.updateAvailable),
|
|
68
|
+
})
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (eligible && !recorded.current) {
|
|
71
|
+
recorded.current = true
|
|
72
|
+
setLatched(true)
|
|
73
|
+
markUsagePromptShown()
|
|
74
|
+
}
|
|
75
|
+
}, [eligible, markUsagePromptShown])
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (answer === null) return
|
|
79
|
+
const t = window.setTimeout(() => setDismissed(true), THANKS_MS)
|
|
80
|
+
return () => window.clearTimeout(t)
|
|
81
|
+
}, [answer])
|
|
82
|
+
|
|
83
|
+
const show = latched && !dismissed && cardStillAsking(status, answer)
|
|
84
|
+
const { shouldRender, isOpen } = useAnimatedUnmount(show, overlayExitMs('menu'))
|
|
85
|
+
if (!shouldRender) return null
|
|
86
|
+
|
|
87
|
+
const readMore = () => {
|
|
88
|
+
setDismissed(true)
|
|
89
|
+
window.dispatchEvent(new CustomEvent('radar:open-settings', { detail: { section: 'privacy' } }))
|
|
90
|
+
}
|
|
91
|
+
const choose = (enabled: boolean) => setUsageData.mutate(enabled, { onSuccess: () => setAnswer(enabled) })
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-label="Help improve Radar"
|
|
97
|
+
inert={!show || undefined}
|
|
98
|
+
className={clsx(
|
|
99
|
+
'fixed bottom-4 right-4 z-40 w-[21rem] max-w-[calc(100vw-2rem)] rounded-xl border border-theme-border bg-theme-surface shadow-theme-lg overflow-hidden origin-bottom-right',
|
|
100
|
+
TRANSITION_MENU,
|
|
101
|
+
isOpen ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-1 scale-[0.97]',
|
|
102
|
+
!show && 'pointer-events-none',
|
|
103
|
+
)}
|
|
104
|
+
style={overlayTransitionStyle(isOpen, 'menu')}
|
|
105
|
+
>
|
|
106
|
+
{answer === null ? (
|
|
107
|
+
<div className="relative flex gap-3 p-3.5 pr-9">
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => setDismissed(true)}
|
|
111
|
+
aria-label="Close"
|
|
112
|
+
className="absolute top-2.5 right-2.5 p-1 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated"
|
|
113
|
+
>
|
|
114
|
+
<X className="w-3.5 h-3.5" />
|
|
115
|
+
</button>
|
|
116
|
+
<span className="flex items-center justify-center w-8 h-8 shrink-0 rounded-lg bg-accent-muted text-accent">
|
|
117
|
+
<BarChart3 className="w-4 h-4" aria-hidden />
|
|
118
|
+
</span>
|
|
119
|
+
<div className="min-w-0">
|
|
120
|
+
<UsageDataBlurb onReadMore={readMore} />
|
|
121
|
+
<div className="flex items-center gap-2 mt-2.5">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
disabled={setUsageData.isPending}
|
|
125
|
+
onClick={() => choose(true)}
|
|
126
|
+
className="btn-brand px-3 py-1 text-xs font-medium rounded-md whitespace-nowrap disabled:opacity-50"
|
|
127
|
+
>
|
|
128
|
+
Send usage stats
|
|
129
|
+
</button>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
disabled={setUsageData.isPending}
|
|
133
|
+
onClick={() => choose(false)}
|
|
134
|
+
className="px-3 py-1 text-xs font-medium rounded-md text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated disabled:opacity-50"
|
|
135
|
+
>
|
|
136
|
+
No thanks
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
<div className="flex items-center gap-2 px-4 py-3 text-xs text-theme-text-secondary">
|
|
143
|
+
<UsageDataAnswered answer={answer} />
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { Megaphone } from 'lucide-react'
|
|
3
|
+
import { nextSeenVersion, whatsNewToShow } from './WhatsNew'
|
|
4
|
+
import { latestReleaseNotesFor, releaseLine, RELEASE_NOTES, releaseNotesFor, type ReleaseNotes } from './releaseNotes'
|
|
5
|
+
import { compareVersions } from '../../utils/version'
|
|
6
|
+
|
|
7
|
+
const entry = (version: string): ReleaseNotes => ({
|
|
8
|
+
version,
|
|
9
|
+
releaseUrl: 'https://github.com/skyhook-io/radar/releases',
|
|
10
|
+
highlights: [{ id: 'x', icon: Megaphone, title: 'X', description: 'Y' }],
|
|
11
|
+
improvements: [],
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const catalog = [entry('v1.15.0'), entry('v2.0.0')]
|
|
15
|
+
|
|
16
|
+
const show = (current: string, lastSeen: string | null, prior = true) =>
|
|
17
|
+
whatsNewToShow(current, lastSeen, prior, catalog)?.version ?? null
|
|
18
|
+
|
|
19
|
+
describe('whatsNewToShow', () => {
|
|
20
|
+
it('shows when the notes are newer than the last version seen', () => {
|
|
21
|
+
expect(show('v2.0.0', 'v1.15.0')).toBe('v2.0.0')
|
|
22
|
+
expect(show('v2.0.0', 'v2.0.0')).toBeNull()
|
|
23
|
+
expect(show('2.0.0', 'v2.0.0')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows the newest earlier notes when the running release has none of its own', () => {
|
|
27
|
+
expect(show('v1.15.1', 'v1.14.1')).toBe('v1.15.0')
|
|
28
|
+
expect(show('v2.3.4', 'v1.15.2')).toBe('v2.0.0')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('stays closed once a later release was seen, including after a downgrade', () => {
|
|
32
|
+
expect(show('v1.15.2', 'v1.15.1')).toBeNull()
|
|
33
|
+
expect(show('v1.15.0', 'v2.0.0')).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('stays closed on a fresh install, and shows for installs that predate the seen record', () => {
|
|
37
|
+
expect(show('v2.0.0', null, false)).toBeNull()
|
|
38
|
+
expect(show('v2.0.0', null, true)).toBe('v2.0.0')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('treats a record that is not a version as an install with nothing seen', () => {
|
|
42
|
+
expect(show('v2.0.0', 'vdev', false)).toBe('v2.0.0')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('stays closed when no notes exist at or below the running version', () => {
|
|
46
|
+
expect(show('v1.14.9', 'v1.14.0')).toBeNull()
|
|
47
|
+
expect(show('dev', 'v1.14.1')).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('nextSeenVersion', () => {
|
|
52
|
+
it('only moves forward', () => {
|
|
53
|
+
expect(nextSeenVersion('v2.0.0', 'v1.15.0')).toBe('v2.0.0')
|
|
54
|
+
expect(nextSeenVersion('2.0.1', null)).toBe('v2.0.1')
|
|
55
|
+
expect(nextSeenVersion('1.15.0', 'v2.0.0')).toBeNull()
|
|
56
|
+
expect(nextSeenVersion('v2.0.0', 'v2.0.0')).toBeNull()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('never records a development build, and replaces a record that is not a version', () => {
|
|
60
|
+
expect(nextSeenVersion('dev', null)).toBeNull()
|
|
61
|
+
expect(nextSeenVersion('dev', 'v2.0.0')).toBeNull()
|
|
62
|
+
expect(nextSeenVersion('v2.0.0', 'vdev')).toBe('v2.0.0')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('release notes lookup', () => {
|
|
67
|
+
it('matches an exact version with or without the v prefix', () => {
|
|
68
|
+
expect(releaseNotesFor('2.0.0', catalog)?.version).toBe('v2.0.0')
|
|
69
|
+
expect(releaseNotesFor(undefined, catalog)).toBeUndefined()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('picks the newest entry at or below a version, whatever the catalog order', () => {
|
|
73
|
+
expect(latestReleaseNotesFor('v2.1.0', [entry('v2.0.0'), entry('v1.15.0')])?.version).toBe('v2.0.0')
|
|
74
|
+
expect(latestReleaseNotesFor('v1.99.0', catalog)?.version).toBe('v1.15.0')
|
|
75
|
+
expect(latestReleaseNotesFor('v1.0.0', catalog)).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('compareVersions', () => {
|
|
80
|
+
it('orders by semver, with a prerelease before its release', () => {
|
|
81
|
+
expect(compareVersions('v1.10.0', 'v1.9.9')).toBeGreaterThan(0)
|
|
82
|
+
expect(compareVersions('1.2.3', 'v1.2.3')).toBe(0)
|
|
83
|
+
expect(compareVersions('v1.2.3-rc.1', 'v1.2.3')).toBeLessThan(0)
|
|
84
|
+
expect(compareVersions('dev', 'v1.2.3')).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('the shipped catalog', () => {
|
|
89
|
+
it('has one well-formed entry per release', () => {
|
|
90
|
+
const versions = RELEASE_NOTES.map(n => n.version)
|
|
91
|
+
expect(new Set(versions).size).toBe(versions.length)
|
|
92
|
+
for (const notes of RELEASE_NOTES) {
|
|
93
|
+
expect(notes.version, notes.version).toMatch(/^v\d+\.\d+\.\d+$/)
|
|
94
|
+
expect(notes.highlights.length, `${notes.version} needs a lead highlight`).toBeGreaterThan(0)
|
|
95
|
+
expect(new Set(notes.highlights.map(h => h.id)).size, `${notes.version} highlight ids`).toBe(notes.highlights.length)
|
|
96
|
+
for (const h of notes.highlights) {
|
|
97
|
+
expect(!!h.path === !!h.cta, `${notes.version}/${h.id}: path and cta go together`).toBe(true)
|
|
98
|
+
if (h.path) expect(h.path, `${notes.version}/${h.id}`).toMatch(/^\//)
|
|
99
|
+
}
|
|
100
|
+
expect(notes.releaseUrl, notes.version).toMatch(/^https:\/\//)
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('releaseLine', () => {
|
|
106
|
+
it('names the minor line, since its patches show the same notes', () => {
|
|
107
|
+
expect(releaseLine('v1.15.0')).toBe('v1.15')
|
|
108
|
+
expect(releaseLine('v2.0.0')).toBe('v2.0')
|
|
109
|
+
})
|
|
110
|
+
})
|