@skyhook-io/radar-app 1.16.0 → 1.16.2

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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +5 -4
  3. package/src/RadarApp.tsx +10 -2
  4. package/src/api/client.ts +27 -6
  5. package/src/api/client.updateResource.test.tsx +58 -0
  6. package/src/api/diagnose.ts +26 -0
  7. package/src/api/httpErrors.ts +12 -0
  8. package/src/api/radarFeatures.render.test.tsx +34 -1
  9. package/src/api/radarFeatures.test.ts +9 -0
  10. package/src/api/radarFeatures.ts +1 -0
  11. package/src/components/ConnectionErrorView.test.tsx +2 -0
  12. package/src/components/ConnectionErrorView.tsx +2 -1
  13. package/src/components/diagnose/AgentControls.tsx +1 -1
  14. package/src/components/diagnose/AssessmentCard.tsx +11 -1
  15. package/src/components/diagnose/DiagnoseContext.tsx +35 -22
  16. package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
  17. package/src/components/diagnose/Home.tsx +1 -1
  18. package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
  19. package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
  20. package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
  21. package/src/components/diagnose/InvestigationView.tsx +199 -65
  22. package/src/components/diagnose/investigationSourceFocus.ts +6 -6
  23. package/src/components/diagnose/investigationState.test.ts +83 -1
  24. package/src/components/diagnose/investigationState.ts +43 -0
  25. package/src/components/diagnose/launch.ts +2 -2
  26. package/src/components/diagnose/useDisclosureReveal.ts +1 -1
  27. package/src/components/dock/BottomDock.tsx +1 -1
  28. package/src/components/dock/DockContext.tsx +27 -2
  29. package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
  30. package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
  31. package/src/components/dock/LocalTerminalTab.tsx +81 -6
  32. package/src/components/logs/LogsViewer.tsx +3 -0
  33. package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
  34. package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
  35. package/src/components/settings/LocalConnectionSettings.tsx +41 -10
  36. package/src/components/whats-new/releaseNotes.ts +1 -1
  37. package/src/components/workload/WorkloadView.tsx +26 -11
  38. package/src/context/DiagnoseCustomization.tsx +13 -3
  39. package/src/index.ts +4 -2
@@ -70,20 +70,20 @@ export function highlightRelatedEvidence(
70
70
  row: HTMLElement,
71
71
  sourceId?: string,
72
72
  ): void {
73
- const workspace = row.closest("[data-investigation-workspace]");
74
- if (!workspace) return;
75
- workspace
73
+ const view = row.closest("[data-investigation-view]");
74
+ if (!view) return;
75
+ view
76
76
  .querySelectorAll("[data-source-related]")
77
77
  .forEach((node) => node.removeAttribute("data-source-related"));
78
- const findings = workspace.querySelector<HTMLElement>(
78
+ const findings = view.querySelector<HTMLElement>(
79
79
  "[data-investigation-findings-scroll]",
80
80
  );
81
- const activity = workspace.querySelector<HTMLElement>(
81
+ const activity = view.querySelector<HTMLElement>(
82
82
  "[data-investigation-activity-scroll]",
83
83
  );
84
84
  if (!sourceId || !findings?.offsetParent || !activity?.offsetParent) return;
85
85
  const viewport = findings.getBoundingClientRect();
86
- workspace
86
+ view
87
87
  .querySelectorAll<HTMLElement>("[data-evidence-source]")
88
88
  .forEach((card) => {
89
89
  if (
@@ -9,6 +9,7 @@ import {
9
9
  investigationApplyAttemptVerified,
10
10
  investigationAssessmentNeedsCurrentStateVerification,
11
11
  investigationApplyRejectionIsDefinitive,
12
+ investigationFailureShown,
12
13
  investigationApplyTerminalNeedsClusterRefresh,
13
14
  investigationTurnWithTerminalEvent,
14
15
  investigationClosedEventIsLive,
@@ -618,7 +619,9 @@ describe("investigation action gating", () => {
618
619
  expect(
619
620
  canOfferInvestigationApply({ ...base, hasNewerEvidence: true }),
620
621
  ).toBe(false);
621
- expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(false);
622
+ expect(canOfferInvestigationApply({ ...base, canApply: false })).toBe(
623
+ false,
624
+ );
622
625
  expect(
623
626
  canOfferInvestigationApply({ ...base, interactionsBlocked: true }),
624
627
  ).toBe(false);
@@ -1273,3 +1276,82 @@ describe("investigationHealthSignals twins across scopes", () => {
1273
1276
  ]);
1274
1277
  });
1275
1278
  });
1279
+
1280
+ describe("investigationFailureShown", () => {
1281
+ const quota = {
1282
+ status: 402,
1283
+ message: "This month's investigations are used up.",
1284
+ code: "ai_quota_exhausted",
1285
+ reason: "allowance_used_free",
1286
+ action: "upgrade",
1287
+ };
1288
+ const none = {
1289
+ actionError: null,
1290
+ actionRefusal: null,
1291
+ verificationError: null,
1292
+ verificationRefusal: null,
1293
+ savedVerificationError: null,
1294
+ applyOutcomeUncertain: null,
1295
+ };
1296
+
1297
+ it("shows a refused status-check retry over the failed verification it retries", () => {
1298
+ const shown = investigationFailureShown({
1299
+ ...none,
1300
+ savedVerificationError: "The agent stopped before checking.",
1301
+ verificationError: quota.message,
1302
+ verificationRefusal: quota,
1303
+ });
1304
+ expect(shown.message).toBe(quota.message);
1305
+ expect(shown.refusal).toBe(quota);
1306
+ });
1307
+
1308
+ it("offers no action for a verification that ran and failed", () => {
1309
+ const shown = investigationFailureShown({
1310
+ ...none,
1311
+ savedVerificationError: "The agent stopped before checking.",
1312
+ });
1313
+ expect(shown.message).toBe("The agent stopped before checking.");
1314
+ expect(shown.refusal).toBeNull();
1315
+ });
1316
+
1317
+ it("keeps an uncertain apply first and shows a newer refused follow-up beside it", () => {
1318
+ const shown = investigationFailureShown({
1319
+ ...none,
1320
+ applyOutcomeUncertain: "Check current status before applying again.",
1321
+ actionError: quota.message,
1322
+ actionRefusal: quota,
1323
+ });
1324
+ expect(shown.message).toBe("Check current status before applying again.");
1325
+ expect(shown.statusCheckError).toBe(
1326
+ "Check current status before applying again.",
1327
+ );
1328
+ expect(shown.refusal).toBeNull();
1329
+ expect(shown.followUp).toEqual({ message: quota.message, refusal: quota });
1330
+ });
1331
+
1332
+ it("shows a refused follow-up beside a failed verification turn too", () => {
1333
+ const shown = investigationFailureShown({
1334
+ ...none,
1335
+ savedVerificationError: "The agent stopped before checking.",
1336
+ actionError: quota.message,
1337
+ actionRefusal: quota,
1338
+ });
1339
+ expect(shown.message).toBe("The agent stopped before checking.");
1340
+ expect(shown.followUp).toEqual({ message: quota.message, refusal: quota });
1341
+ });
1342
+
1343
+ it("shows a refused follow-up with its action", () => {
1344
+ const shown = investigationFailureShown({
1345
+ ...none,
1346
+ actionError: quota.message,
1347
+ actionRefusal: quota,
1348
+ });
1349
+ expect(shown).toEqual({
1350
+ verificationError: null,
1351
+ statusCheckError: null,
1352
+ message: quota.message,
1353
+ refusal: quota,
1354
+ followUp: null,
1355
+ });
1356
+ });
1357
+ });
@@ -5,6 +5,7 @@ import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
5
5
  import {
6
6
  DiagnoseError,
7
7
  type DiagnoseStreamEvent,
8
+ type InvestigationRefusal,
8
9
  type RunSummary,
9
10
  } from "../../api/diagnose";
10
11
  import type { Turn } from "./parts";
@@ -123,6 +124,48 @@ export function investigationAssessmentNeedsCurrentStateVerification(input: {
123
124
  return input.localApplyAttemptAssessmentIdx >= input.currentAssessmentIdx;
124
125
  }
125
126
 
127
+ // Which failure the activity pane shows, and the host refusal behind it. A
128
+ // request's own error is newer than a saved verification turn's: it is cleared
129
+ // when the next request starts and when a new verification turn streams.
130
+ // A status warning keeps first place, since it is what stops a second Apply
131
+ // before anyone checks; a newer failed follow-up shows beside it, not instead.
132
+ export function investigationFailureShown(input: {
133
+ actionError: string | null;
134
+ actionRefusal: InvestigationRefusal | null;
135
+ verificationError: string | null;
136
+ verificationRefusal: InvestigationRefusal | null;
137
+ savedVerificationError: string | null;
138
+ applyOutcomeUncertain: string | null;
139
+ }): {
140
+ verificationError: string | null;
141
+ statusCheckError: string | null;
142
+ message: string | null;
143
+ refusal: InvestigationRefusal | null;
144
+ followUp: { message: string; refusal: InvestigationRefusal | null } | null;
145
+ } {
146
+ const verificationError =
147
+ input.verificationError || input.savedVerificationError;
148
+ const statusCheckError = verificationError || input.applyOutcomeUncertain;
149
+ if (statusCheckError) {
150
+ return {
151
+ verificationError,
152
+ statusCheckError,
153
+ message: statusCheckError,
154
+ refusal: input.verificationError ? input.verificationRefusal : null,
155
+ followUp: input.actionError
156
+ ? { message: input.actionError, refusal: input.actionRefusal }
157
+ : null,
158
+ };
159
+ }
160
+ return {
161
+ verificationError: null,
162
+ statusCheckError: null,
163
+ message: input.actionError,
164
+ refusal: input.actionError ? input.actionRefusal : null,
165
+ followUp: null,
166
+ };
167
+ }
168
+
126
169
  export function investigationApplyRejectionIsDefinitive(
127
170
  error: unknown,
128
171
  ): error is DiagnoseError {
@@ -43,12 +43,12 @@ export function buildLaunchCommand(
43
43
  ): string | null {
44
44
  if (!run.sessionId || run.status === "stale") return null;
45
45
 
46
- // OpenCode terminal handoff needs a persistent MCP configuration outside the run workspace.
46
+ // OpenCode terminal handoff needs a persistent MCP configuration outside the run's working directory.
47
47
  if (run.agent === "opencode") return null;
48
48
 
49
49
  if (run.agent === "cursor-agent") {
50
50
  // Cursor's --resume is workspace-scoped, and Radar runs each investigation in a
51
- // throwaway per-run workspace the user doesn't have — no command can reattach
51
+ // throwaway per-run working directory the user doesn't have — no command can reattach
52
52
  // both that session and Radar's MCP in the user's own terminal. So there's no
53
53
  // hand-off for Cursor; the in-panel investigation is self-contained.
54
54
  return null;
@@ -84,7 +84,7 @@ export function useDisclosureReveal<T extends HTMLElement>() {
84
84
  );
85
85
  // This component lives in a bounded Diagnose surface. Never fall back to
86
86
  // scrolling the document (or an overflow-hidden ancestor), which can move
87
- // the entire workspace and expose blank space below it.
87
+ // the entire investigation surface and expose blank space below it.
88
88
  if (!scroller) return;
89
89
  // Expansion is delayed until Collapse has settled. If the reader scrolls
90
90
  // during that interval, their newer intent wins over the automatic reveal.
@@ -53,7 +53,7 @@ function renderTabContent(tab: DockTab, isActive: boolean) {
53
53
 
54
54
  if (tab.type === 'local-terminal') {
55
55
  return (
56
- <LocalTerminalTab isActive={isActive} initialCommand={tab.initialCommand} />
56
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext} isActive={isActive} initialCommand={tab.initialCommand} />
57
57
  )
58
58
  }
59
59
 
@@ -1,4 +1,8 @@
1
- // Re-exported from @skyhook-io/k8s-ui — source of truth is radar/packages/k8s-ui/src/components/dock/DockContext.tsx
1
+ import { useCallback, useLayoutEffect, useRef } from 'react'
2
+ import { useDock } from '@skyhook-io/k8s-ui'
3
+ import { useConnection } from '../../context/ConnectionContext'
4
+ import { useToast } from '../ui/Toast'
5
+
2
6
  export {
3
7
  DockProvider,
4
8
  useDock,
@@ -7,6 +11,27 @@ export {
7
11
  useOpenLogs,
8
12
  useOpenWorkloadLogs,
9
13
  useOpenNodeTerminal,
10
- useOpenLocalTerminal,
11
14
  } from '@skyhook-io/k8s-ui'
12
15
  export type { DockTab, DockTabType, DockContextValue } from '@skyhook-io/k8s-ui'
16
+
17
+ export function useOpenLocalTerminal() {
18
+ const { addTab } = useDock()
19
+ const { connection } = useConnection()
20
+ const { showToast } = useToast()
21
+ const contextRef = useRef({ context: connection.context, state: connection.state })
22
+ useLayoutEffect(() => { contextRef.current = { context: connection.context, state: connection.state } }, [connection.context, connection.state])
23
+
24
+ return useCallback((opts?: { initialCommand?: string; title?: string }) => {
25
+ const { context, state } = contextRef.current
26
+ if (!context && state !== 'disconnected') {
27
+ showToast('Waiting for a context. Try opening the terminal again.', { type: 'info' })
28
+ return
29
+ }
30
+ addTab({
31
+ type: 'local-terminal',
32
+ title: opts?.title || 'Terminal',
33
+ initialCommand: opts?.initialCommand,
34
+ localTerminalContext: context,
35
+ })
36
+ }, [addTab, showToast])
37
+ }
@@ -0,0 +1,210 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRequire } from 'node:module'
4
+ import { resolve } from 'node:path'
5
+ import { createRoot, type Root } from 'react-dom/client'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
8
+ import { DockProvider, useDock, useOpenLocalTerminal as useSharedOpenLocalTerminal } from '../../../../packages/k8s-ui/src/components/dock/DockContext'
9
+ import { useOpenLocalTerminal } from './DockContext'
10
+ import { LocalTerminalTab } from './LocalTerminalTab'
11
+ import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast'
12
+
13
+ const state = vi.hoisted(() => ({ context: 'A', status: 'connected', mountTabs: true, open: null as null | (() => void) }))
14
+ vi.mock('../../context/ConnectionContext', () => ({
15
+ useConnection: () => ({ connection: { context: state.context, state: state.status } }),
16
+ }))
17
+ vi.mock('@skyhook-io/k8s-ui', async () => ({
18
+ ...await import('../../../../packages/k8s-ui/src/components/dock/DockContext'),
19
+ ...await import('../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab'),
20
+ ...await import('../../../../packages/k8s-ui/src/components/ui/ClusterName'),
21
+ ...await import('../../../../packages/k8s-ui/src/components/ui/Tooltip'),
22
+ ...await import('../../../../packages/k8s-ui/src/components/ui/Toast'),
23
+ ...await import('../../../../packages/k8s-ui/src/utils/context-name'),
24
+ }))
25
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/terminalClipboard', () => ({ setupTerminalClipboard: () => () => {}, copyTerminalSelection: vi.fn() }))
26
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/TerminalClipboardToolbar', () => ({ TerminalClipboardToolbar: () => null }))
27
+ vi.mock('../../../../packages/k8s-ui/src/components/dock/useMultilinePasteConfirm', () => ({ useMultilinePasteConfirm: () => ({ confirmPaste: vi.fn(), pasteDialog: null }) }))
28
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
29
+ const { Terminal } = createRequire(resolve(__dirname, '../../../../packages/k8s-ui/package.json'))('@xterm/xterm')
30
+
31
+ class Socket {
32
+ static OPEN = 1
33
+ static instances: Socket[] = []
34
+ readyState = 0
35
+ onopen?: () => void
36
+ onclose?: () => void
37
+ onerror?: () => void
38
+ onmessage?: (event: { data: string }) => void
39
+ send = vi.fn()
40
+ constructor(public url: string) { Socket.instances.push(this) }
41
+ open() { this.readyState = 1; this.onopen?.() }
42
+ close() { this.readyState = 3; this.onclose?.() }
43
+ session(context: string) {
44
+ this.onmessage?.({ data: JSON.stringify({ type: 'session', context, kubeconfigIsolated: true }) })
45
+ }
46
+ }
47
+ let root: Root
48
+ let element: HTMLDivElement
49
+ let client: QueryClient
50
+ function Harness() {
51
+ const { tabs } = useDock()
52
+ const open = useOpenLocalTerminal()
53
+ const sharedOpen = useSharedOpenLocalTerminal()
54
+ state.open ??= () => open({ title: 'Auth', initialCommand: 'auth-marker' })
55
+ return <>
56
+ <button onClick={() => open({ title: 'Auth', initialCommand: 'auth-marker' })}>Open</button>
57
+ <button onClick={() => sharedOpen()}>Open shared</button>
58
+ {state.mountTabs && tabs.map(tab => <section key={tab.id}>
59
+ <h2>{tab.title}</h2>
60
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext} initialCommand={tab.initialCommand} />
61
+ </section>)}
62
+ </>
63
+ }
64
+ async function render() {
65
+ await act(async () => root.render(<QueryClientProvider client={client}><ToastProvider><DockProvider><Harness /></DockProvider></ToastProvider></QueryClientProvider>))
66
+ }
67
+ async function click(text: string, section: ParentNode = element) {
68
+ await act(async () => [...section.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent?.trim() === text)!.click())
69
+ }
70
+ beforeEach(() => {
71
+ vi.spyOn(Terminal.prototype, 'open').mockImplementation(() => {})
72
+ state.context = 'A'
73
+ state.status = 'connected'
74
+ state.mountTabs = true
75
+ state.open = null
76
+ Socket.instances = []
77
+ vi.useFakeTimers()
78
+ vi.stubGlobal('WebSocket', Socket)
79
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
80
+ vi.stubGlobal('requestAnimationFrame', () => 0)
81
+ client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
82
+ element = document.createElement('div')
83
+ document.body.appendChild(element)
84
+ root = createRoot(element)
85
+ })
86
+ afterEach(async () => {
87
+ await act(async () => root.unmount())
88
+ client.clear()
89
+ element.remove()
90
+ vi.unstubAllGlobals()
91
+ vi.restoreAllMocks()
92
+ vi.useRealTimers()
93
+ })
94
+
95
+ it('captures A at the action before mounting, and keeps first delivery pending while B is selected', async () => {
96
+ state.mountTabs = false
97
+ await render()
98
+ await click('Open')
99
+ state.context = 'B'
100
+ state.mountTabs = true
101
+ await render()
102
+ expect(Socket.instances).toHaveLength(0)
103
+ expect(element.textContent).toContain('Requested:A')
104
+ expect(element.querySelector('button[aria-label="New terminal for B"]')).not.toBeNull()
105
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
106
+ expect(element.textContent).toContain('Different context')
107
+ state.context = 'A'
108
+ await render()
109
+ await click('Reconnect')
110
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A')
111
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) })
112
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toEqual([[JSON.stringify({ type: 'input', data: 'auth-marker\n' })]])
113
+ })
114
+
115
+ it('preserves A through a refused reconnect, creates B independently, and reconnects A without replay', async () => {
116
+ await render()
117
+ await click('Open')
118
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('A'); vi.advanceTimersByTime(300) })
119
+ state.context = 'B'
120
+ await render()
121
+ expect(Socket.instances[0].readyState).toBe(1)
122
+ await act(async () => Socket.instances[0].close())
123
+ await click('Reconnect')
124
+ expect(Socket.instances).toHaveLength(1)
125
+ expect(element.querySelector('h2')?.textContent).toBe('Auth · A')
126
+ await click('New terminal')
127
+ expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('B')
128
+ await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('B') })
129
+ expect([...element.querySelectorAll('h2')].map(e => e.textContent)).toEqual(['Auth · A', 'Terminal · B'])
130
+ state.context = 'A'
131
+ await render()
132
+ await click('Reconnect', element.querySelector('section')!)
133
+ await act(async () => { Socket.instances[2].open(); Socket.instances[2].session('A'); vi.advanceTimersByTime(300) })
134
+ expect(new URL(Socket.instances[2].url).searchParams.get('expectedContext')).toBe('A')
135
+ expect(Socket.instances.flatMap(s => s.send.mock.calls).filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
136
+ })
137
+
138
+ it('refreshes connection state after handshake failure and can retry without consuming the command', async () => {
139
+ const refresh = vi.spyOn(client, 'invalidateQueries')
140
+ await render()
141
+ await click('Open')
142
+ await act(async () => { Socket.instances[0].onerror?.(); Socket.instances[0].close() })
143
+ expect(refresh).toHaveBeenCalledWith({ queryKey: ['connection-status'] })
144
+ state.context = 'B'
145
+ await render()
146
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Retry')?.disabled).toBe(true)
147
+ await click('Retry')
148
+ expect(Socket.instances).toHaveLength(1)
149
+ state.context = 'A'
150
+ await render()
151
+ await click('Retry')
152
+ await act(async () => { Socket.instances[1].open(); Socket.instances[1].session('A'); vi.advanceTimersByTime(300) })
153
+ expect(Socket.instances[1].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
154
+ })
155
+
156
+ it('explains unknown-context refusal and lets an earlier callback read the latest committed context', async () => {
157
+ state.context = ''
158
+ state.status = 'connecting'
159
+ await render()
160
+ await click('Open')
161
+ expect(element.querySelector('section')).toBeNull()
162
+ expect(element.textContent).toContain('Waiting for a context. Try opening the terminal again.')
163
+ expect(Socket.instances).toHaveLength(0)
164
+ state.context = 'A & B/qualified'
165
+ await render()
166
+ expect(Socket.instances).toHaveLength(0)
167
+ await act(async () => state.open!())
168
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A & B/qualified')
169
+ })
170
+
171
+ it('gives a shared-hook tab without an intent an explicit state and a fresh context-aware action', async () => {
172
+ await render()
173
+ await click('Open shared')
174
+ expect(element.textContent).toContain('No context selected')
175
+ expect(Socket.instances).toHaveLength(0)
176
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
177
+ await click('New terminal')
178
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('A')
179
+ })
180
+
181
+ it('preserves the no-kubeconfig recovery shell with an explicit empty intent, then blocks reconnect under A', async () => {
182
+ state.context = ''
183
+ state.status = 'disconnected'
184
+ await render()
185
+ await click('Open')
186
+ const query = new URL(Socket.instances[0].url).searchParams
187
+ expect(query.has('expectedContext')).toBe(true)
188
+ expect(query.get('expectedContext')).toBe('')
189
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].onmessage?.({ data: JSON.stringify({ type: 'session', context: '', kubeconfigIsolated: false }) }); vi.advanceTimersByTime(300) })
190
+ expect(element.textContent).toContain('Context not confirmed')
191
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
192
+ state.context = 'A'
193
+ state.status = 'connected'
194
+ await render()
195
+ expect(Socket.instances[0].readyState).toBe(1)
196
+ await act(async () => Socket.instances[0].close())
197
+ expect([...element.querySelectorAll('button')].find(b => b.textContent?.trim() === 'Reconnect')?.disabled).toBe(true)
198
+ await click('New terminal')
199
+ expect(new URL(Socket.instances[1].url).searchParams.get('expectedContext')).toBe('A')
200
+ })
201
+
202
+ it('opens a recovery shell for the known context when startup reports a config error', async () => {
203
+ state.context = 'production@secondary'
204
+ state.status = 'disconnected'
205
+ await render()
206
+ await click('Open')
207
+ expect(new URL(Socket.instances[0].url).searchParams.get('expectedContext')).toBe('production@secondary')
208
+ await act(async () => { Socket.instances[0].open(); Socket.instances[0].session('production@secondary'); vi.advanceTimersByTime(300) })
209
+ expect(Socket.instances[0].send.mock.calls.filter(([data]) => JSON.parse(data).type === 'input')).toHaveLength(1)
210
+ })
@@ -0,0 +1,122 @@
1
+ // @vitest-environment jsdom
2
+ import { act, useEffect } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
5
+ import { DockProvider, useDock } from '../../../../packages/k8s-ui/src/components/dock/DockContext'
6
+ import type { LocalTerminalTabProps } from '../../../../packages/k8s-ui/src/components/dock/LocalTerminalTab'
7
+ import { LocalTerminalTab } from './LocalTerminalTab'
8
+ import { ToastProvider } from '../../../../packages/k8s-ui/src/components/ui/Toast'
9
+
10
+ const state = vi.hoisted(() => ({
11
+ context: 'staging',
12
+ firstContext: 'production',
13
+ props: new Map<string, LocalTerminalTabProps>(),
14
+ }))
15
+ vi.mock('../../context/ConnectionContext', () => ({
16
+ useConnection: () => ({ connection: { context: state.context } }),
17
+ }))
18
+ vi.mock('@tanstack/react-query', () => ({
19
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
20
+ }))
21
+ vi.mock('@skyhook-io/k8s-ui', async () => {
22
+ const dock = await import('../../../../packages/k8s-ui/src/components/dock/DockContext')
23
+ const { ClusterName } = await import('../../../../packages/k8s-ui/src/components/ui/ClusterName')
24
+ const { Tooltip } = await import('../../../../packages/k8s-ui/src/components/ui/Tooltip')
25
+ const { parseContextName } = await import('../../../../packages/k8s-ui/src/utils/context-name')
26
+ const { useToast } = await import('../../../../packages/k8s-ui/src/components/ui/Toast')
27
+ return {
28
+ ...dock, ClusterName, Tooltip, parseContextName, useToast,
29
+ LocalTerminalTab: (props: LocalTerminalTabProps) => {
30
+ state.props.set(props.initialCommand!, props)
31
+ return <div data-testid="terminal-toolbar">{props.toolbarExtra}</div>
32
+ },
33
+ }
34
+ })
35
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
36
+
37
+ let root: Root
38
+ let element: HTMLDivElement
39
+ function Harness() {
40
+ const { tabs, addTab } = useDock()
41
+ useEffect(() => {
42
+ addTab({ type: 'local-terminal', title: 'Terminal', initialCommand: 'first', localTerminalContext: state.firstContext })
43
+ addTab({ type: 'local-terminal', title: 'Auth', initialCommand: 'second', localTerminalContext: 'staging' })
44
+ }, [addTab])
45
+ return <>{tabs.map(tab => (
46
+ <section key={tab.id}>
47
+ <h2 data-tooltip={tab.titleTooltip}>{tab.title}</h2>
48
+ <LocalTerminalTab tabId={tab.id} title={tab.title} intendedContext={tab.localTerminalContext!} initialCommand={tab.initialCommand} />
49
+ </section>
50
+ ))}</>
51
+ }
52
+ async function render() {
53
+ await act(async () => root.render(<ToastProvider><DockProvider><Harness /></DockProvider></ToastProvider>))
54
+ }
55
+ beforeEach(() => {
56
+ state.context = 'staging'
57
+ state.firstContext = 'production'
58
+ state.props.clear()
59
+ element = document.createElement('div')
60
+ document.body.appendChild(element)
61
+ root = createRoot(element)
62
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
63
+ })
64
+ afterEach(async () => {
65
+ await act(async () => root.unmount())
66
+ element.remove()
67
+ vi.unstubAllGlobals()
68
+ })
69
+
70
+ it('waits for server metadata, labels each tab independently, and notices a UI switch', async () => {
71
+ await render()
72
+ expect(element.textContent).toContain('Requested:production')
73
+ await act(async () => {
74
+ state.props.get('first')!.onSessionInfo!({ context: 'production', kubeconfigIsolated: true })
75
+ state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true })
76
+ })
77
+ expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging'])
78
+ expect(element.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for production')
79
+ expect(element.querySelector('[role="status"]')?.closest('[data-testid="terminal-toolbar"]')).not.toBeNull()
80
+ state.context = 'production'
81
+ await render()
82
+ const sections = element.querySelectorAll('section')
83
+ expect(sections[0].querySelector('[role="status"]')).toBeNull()
84
+ expect(sections[0].textContent).toContain('Opened for:production')
85
+ expect(sections[1].querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('This terminal is for staging')
86
+ await act(async () => sections[1].querySelector<HTMLButtonElement>('button')!.click())
87
+ expect([...element.querySelectorAll('h2')].map(el => el.textContent)).toEqual(['Terminal · production', 'Auth · staging', 'Terminal'])
88
+ })
89
+
90
+ it('marks fallback sessions unconfirmed and replaces labels for a newly connected shell', async () => {
91
+ await render()
92
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true }))
93
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: '', kubeconfigIsolated: false }))
94
+ const section = element.querySelectorAll('section')[1]
95
+ expect(section.textContent).toContain('Auth · context not confirmed')
96
+ expect(section.textContent).toContain('Context not confirmed')
97
+ expect(section.querySelector('[role="status"]')).toBeNull()
98
+ state.context = 'production'
99
+ await render()
100
+ expect(section.querySelector('[role="status"]')?.textContent).toContain('Requested context differs')
101
+ expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).toContain('Requested for staging; kubeconfig not confirmed')
102
+ expect(section.querySelector('[role="status"]')?.getAttribute('aria-label')).not.toContain('This terminal is for staging')
103
+ await act(async () => state.props.get('second')!.onSessionInfo!(null))
104
+ expect(section.querySelector('h2')?.textContent).toBe('Auth · context not confirmed')
105
+ await act(async () => state.props.get('second')!.onSessionInfo!({ context: 'staging', kubeconfigIsolated: true }))
106
+ expect(section.querySelector('h2')?.textContent).toBe('Auth · staging')
107
+ })
108
+
109
+ it('compares full contexts even when both have the same short cluster name', async () => {
110
+ state.context = 'gke_project-b_us-east1_production'
111
+ state.firstContext = 'gke_project-a_us-east1_production'
112
+ await render()
113
+ await act(async () => state.props.get('first')!.onSessionInfo!({ context: 'gke_project-a_us-east1_production', kubeconfigIsolated: true }))
114
+ expect(element.querySelector('h2')?.textContent).toBe('Terminal · production')
115
+ expect(element.querySelector('h2')?.getAttribute('data-tooltip')).toBe('gke_project-a_us-east1_production')
116
+ const message = element.querySelector('[role="status"]')?.getAttribute('aria-label')
117
+ expect(message).toContain('gke_project-a_us-east1_production')
118
+ expect(message).toContain('gke_project-b_us-east1_production')
119
+ state.context = ''
120
+ await render()
121
+ expect(element.querySelector('[role="status"]')).toBeNull()
122
+ })