@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.
- package/package.json +2 -2
- package/src/App.tsx +5 -4
- package/src/RadarApp.tsx +10 -2
- package/src/api/client.ts +27 -6
- package/src/api/client.updateResource.test.tsx +58 -0
- package/src/api/diagnose.ts +26 -0
- package/src/api/httpErrors.ts +12 -0
- package/src/api/radarFeatures.render.test.tsx +34 -1
- package/src/api/radarFeatures.test.ts +9 -0
- package/src/api/radarFeatures.ts +1 -0
- package/src/components/ConnectionErrorView.test.tsx +2 -0
- package/src/components/ConnectionErrorView.tsx +2 -1
- package/src/components/diagnose/AgentControls.tsx +1 -1
- package/src/components/diagnose/AssessmentCard.tsx +11 -1
- package/src/components/diagnose/DiagnoseContext.tsx +35 -22
- package/src/components/diagnose/DiagnoseSurface.tsx +12 -8
- package/src/components/diagnose/Home.tsx +1 -1
- package/src/components/diagnose/InvestigationEvidencePane.tsx +1 -1
- package/src/components/diagnose/InvestigationRefusalAction.test.tsx +156 -0
- package/src/components/diagnose/InvestigationRefusalAction.tsx +14 -0
- package/src/components/diagnose/InvestigationView.tsx +199 -65
- package/src/components/diagnose/investigationSourceFocus.ts +6 -6
- package/src/components/diagnose/investigationState.test.ts +83 -1
- package/src/components/diagnose/investigationState.ts +43 -0
- package/src/components/diagnose/launch.ts +2 -2
- package/src/components/diagnose/useDisclosureReveal.ts +1 -1
- package/src/components/dock/BottomDock.tsx +1 -1
- package/src/components/dock/DockContext.tsx +27 -2
- package/src/components/dock/LocalTerminalContext.test.tsx +210 -0
- package/src/components/dock/LocalTerminalTab.test.tsx +122 -0
- package/src/components/dock/LocalTerminalTab.tsx +81 -6
- package/src/components/logs/LogsViewer.tsx +3 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +3 -0
- package/src/components/settings/LocalConfigurationDetails.tsx +21 -15
- package/src/components/settings/LocalConnectionSettings.tsx +41 -10
- package/src/components/whats-new/releaseNotes.ts +1 -1
- package/src/components/workload/WorkloadView.tsx +26 -11
- package/src/context/DiagnoseCustomization.tsx +13 -3
- 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
|
|
74
|
-
if (!
|
|
75
|
-
|
|
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 =
|
|
78
|
+
const findings = view.querySelector<HTMLElement>(
|
|
79
79
|
"[data-investigation-findings-scroll]",
|
|
80
80
|
);
|
|
81
|
-
const activity =
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
})
|