@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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/App.tsx +51 -6
  3. package/src/api/client.admission.test.ts +34 -0
  4. package/src/api/client.ts +37 -3
  5. package/src/api/usage-data.fixtures.ts +33 -0
  6. package/src/api/usage-data.ts +165 -0
  7. package/src/components/CloudFunnelButton.test.tsx +2 -2
  8. package/src/components/CloudFunnelButton.tsx +99 -55
  9. package/src/components/SelfManagedStart.tsx +96 -0
  10. package/src/components/cloudConnectHandoff.test.ts +1 -1
  11. package/src/components/cloudConnectHandoff.ts +1 -1
  12. package/src/components/diagnose/ActivityTurn.tsx +3 -2
  13. package/src/components/diagnose/ApplyDialog.tsx +4 -2
  14. package/src/components/dialog-names.test.ts +69 -0
  15. package/src/components/execution/BatchExecutionView.tsx +1 -1
  16. package/src/components/execution/JobSetAdmission.test.tsx +160 -37
  17. package/src/components/execution/JobSetAdmission.tsx +16 -7
  18. package/src/components/helm/TrackChartSourceDialog.tsx +5 -3
  19. package/src/components/home/HomeView.tsx +6 -2
  20. package/src/components/home/RadarVersionLine.test.tsx +14 -0
  21. package/src/components/home/RadarVersionLine.tsx +28 -3
  22. package/src/components/nav/PrimaryNavRail.test.tsx +10 -1
  23. package/src/components/nav/PrimaryNavRail.tsx +25 -3
  24. package/src/components/resources/renderers/JobAdmissionRenderers.test.tsx +18 -0
  25. package/src/components/resources/renderers/JobAdmissionRenderers.tsx +18 -0
  26. package/src/components/resources/renderers/RayJobRenderer.test.tsx +40 -0
  27. package/src/components/resources/renderers/RayJobRenderer.tsx +45 -0
  28. package/src/components/settings/PrivacySection.test.tsx +84 -0
  29. package/src/components/settings/PrivacySection.tsx +140 -0
  30. package/src/components/settings/SettingsDialog.tsx +13 -52
  31. package/src/components/settings/controls.tsx +55 -0
  32. package/src/components/settings/settings-state.ts +1 -0
  33. package/src/components/ui/ErrorBoundary.tsx +5 -0
  34. package/src/components/ui/Omnibar.tsx +16 -0
  35. package/src/components/ui/UpdateNotification.tsx +13 -9
  36. package/src/components/ui/command-items.ts +14 -0
  37. package/src/components/usage-data/UsageDataAsk.test.tsx +41 -0
  38. package/src/components/usage-data/UsageDataAsk.tsx +106 -0
  39. package/src/components/usage-data/UsageDataPrompt.test.ts +53 -0
  40. package/src/components/usage-data/UsageDataPrompt.tsx +148 -0
  41. package/src/components/whats-new/WhatsNew.test.ts +110 -0
  42. package/src/components/whats-new/WhatsNew.tsx +423 -0
  43. package/src/components/whats-new/WhatsNewDialog.test.tsx +229 -0
  44. package/src/components/whats-new/check-whats-new.test.ts +65 -0
  45. package/src/components/whats-new/releaseNotes.ts +121 -0
  46. package/src/components/workload/WorkloadView.tsx +11 -1
  47. package/src/k8s-ui-exports.test.ts +73 -0
  48. package/src/utils/navigation.test.ts +17 -1
  49. package/src/utils/navigation.ts +16 -0
  50. 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
+ })