@skyhook-io/radar-app 1.13.1 → 1.13.3
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 +19 -1
- package/src/RadarApp.tsx +2 -2
- package/src/api/diagnose.test.ts +268 -0
- package/src/api/diagnose.ts +74 -9
- package/src/components/diagnose/AISettings.tsx +1 -1
- package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
- package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
- package/src/components/diagnose/DiagnoseContext.tsx +12 -17
- package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
- package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
- package/src/components/diagnose/Home.test.tsx +293 -0
- package/src/components/diagnose/Home.tsx +289 -119
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
- package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
- package/src/components/diagnose/InvestigationView.test.ts +17 -0
- package/src/components/diagnose/InvestigationView.tsx +1913 -395
- package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
- package/src/components/diagnose/agentCatalog.ts +1 -1
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
- package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
- package/src/components/diagnose/investigationEvidence.ts +3492 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
- package/src/components/diagnose/investigationExplanation.test.ts +92 -0
- package/src/components/diagnose/investigationExplanation.ts +29 -0
- package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
- package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
- package/src/components/diagnose/investigationSourceFocus.ts +98 -0
- package/src/components/diagnose/investigationState.test.ts +695 -0
- package/src/components/diagnose/investigationState.ts +451 -0
- package/src/components/diagnose/parts.test.tsx +864 -3
- package/src/components/diagnose/parts.tsx +1337 -541
- package/src/components/diagnose/target.test.ts +39 -0
- package/src/components/diagnose/target.ts +36 -0
- package/src/components/diagnose/useDisclosureReveal.ts +117 -0
- package/src/components/home/MCPSetupDialog.tsx +2 -2
- package/src/components/home/mcpToolCatalog.test.ts +22 -0
- package/src/components/home/mcpToolCatalog.ts +3 -2
- package/src/components/issues/IssuesPane.tsx +5 -1
- package/src/components/settings/SettingsDialog.tsx +11 -13
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/context/DiagnoseCustomization.tsx +11 -8
- package/src/index.css +63 -79
- package/src/index.ts +1 -1
|
@@ -1,10 +1,337 @@
|
|
|
1
1
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
AgentControls,
|
|
5
|
+
AssessmentSources,
|
|
6
|
+
ConsentCard,
|
|
7
|
+
ResultCard,
|
|
8
|
+
Timeline,
|
|
9
|
+
TurnView,
|
|
10
|
+
appendThinking,
|
|
11
|
+
upsertTool,
|
|
12
|
+
type TimelineItem,
|
|
13
|
+
type Turn,
|
|
14
|
+
} from "./parts";
|
|
15
|
+
import type {
|
|
16
|
+
AgentInfo,
|
|
17
|
+
Diagnosis,
|
|
18
|
+
DiagnoseStep,
|
|
19
|
+
ExecutionProfile,
|
|
20
|
+
} from "../../api/diagnose";
|
|
21
|
+
import { investigationEvidenceSourceId } from "./investigationEvidence";
|
|
22
|
+
import { ThemeProvider } from "../../context/ThemeContext";
|
|
5
23
|
|
|
6
24
|
const noop = vi.fn();
|
|
7
25
|
|
|
26
|
+
it("keeps earlier remediation copyable without suggesting it is executable", () => {
|
|
27
|
+
const html = renderToStaticMarkup(
|
|
28
|
+
<ResultCard
|
|
29
|
+
diagnosis={
|
|
30
|
+
{
|
|
31
|
+
rootCause: "Missing configuration",
|
|
32
|
+
report: "Assessment",
|
|
33
|
+
remediation: ["Restore the required Secret"],
|
|
34
|
+
recommendedIndex: 1,
|
|
35
|
+
} as Diagnosis
|
|
36
|
+
}
|
|
37
|
+
section="actions"
|
|
38
|
+
compactActions
|
|
39
|
+
actionNotice="Proposed before the latest evidence. Reassess before applying."
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
expect(html).toContain("Restore the required Secret");
|
|
43
|
+
expect(html).toContain("Copy remediation step 1");
|
|
44
|
+
expect(html).toContain("Reassess before applying");
|
|
45
|
+
expect(html).not.toContain("Apply…");
|
|
46
|
+
expect(html).not.toContain("ask the agent to continue");
|
|
47
|
+
expect(html.indexOf("Reassess before applying")).toBeLessThan(
|
|
48
|
+
html.indexOf("Restore the required Secret"),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("explanation placement", () => {
|
|
53
|
+
it("gives analysis and explanation separate animated disclosure regions", () => {
|
|
54
|
+
const html = renderToStaticMarkup(
|
|
55
|
+
<ResultCard
|
|
56
|
+
diagnosis={
|
|
57
|
+
{
|
|
58
|
+
rootCause: "Missing Secret",
|
|
59
|
+
report: "Technical analysis",
|
|
60
|
+
remediation: [],
|
|
61
|
+
} as Diagnosis
|
|
62
|
+
}
|
|
63
|
+
section="conclusion"
|
|
64
|
+
explanation={{ status: "running" }}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
const controls = [...html.matchAll(/aria-controls="([^"]+)"/g)].map(
|
|
68
|
+
(match) => match[1],
|
|
69
|
+
);
|
|
70
|
+
const analysis = controls.find((id) => id.endsWith("-analysis"));
|
|
71
|
+
const explanation = controls.find((id) => id.endsWith("-explanation"));
|
|
72
|
+
expect(analysis).toBeDefined();
|
|
73
|
+
expect(explanation).toBeDefined();
|
|
74
|
+
expect(analysis).not.toBe(explanation);
|
|
75
|
+
expect(html).toContain(`id="${analysis}"`);
|
|
76
|
+
expect(html).toContain(`id="${explanation}"`);
|
|
77
|
+
expect(
|
|
78
|
+
html.match(/transition-\[grid-template-rows\]/g)?.length,
|
|
79
|
+
).toBeGreaterThanOrEqual(2);
|
|
80
|
+
expect(html).not.toContain("Technical analysis");
|
|
81
|
+
expect(html).toContain("Explaining this assessment");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("shows local progress in the assessment's shared disclosure", () => {
|
|
85
|
+
const html = renderToStaticMarkup(
|
|
86
|
+
<ResultCard
|
|
87
|
+
diagnosis={
|
|
88
|
+
{
|
|
89
|
+
rootCause: "Missing Secret",
|
|
90
|
+
report: "Technical analysis",
|
|
91
|
+
remediation: [],
|
|
92
|
+
} as Diagnosis
|
|
93
|
+
}
|
|
94
|
+
section="conclusion"
|
|
95
|
+
explanation={{ status: "running" }}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
expect(html).toContain("Explaining this assessment");
|
|
99
|
+
expect(html).toContain('role="status"');
|
|
100
|
+
expect(html).toContain("Full analysis");
|
|
101
|
+
expect(html).not.toContain("Agent confidence:");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("keeps a saved explanation accessible without permission to start another turn", () => {
|
|
105
|
+
const html = renderToStaticMarkup(
|
|
106
|
+
<ResultCard
|
|
107
|
+
diagnosis={
|
|
108
|
+
{
|
|
109
|
+
rootCause: "Missing Secret",
|
|
110
|
+
remediation: [],
|
|
111
|
+
} as unknown as Diagnosis
|
|
112
|
+
}
|
|
113
|
+
section="conclusion"
|
|
114
|
+
explanation={{ status: "done", text: "Saved explanation" }}
|
|
115
|
+
/>,
|
|
116
|
+
);
|
|
117
|
+
expect(html).toContain("Explain simply");
|
|
118
|
+
expect(html).not.toContain('disabled=""');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("shows a compact Activity receipt instead of repeating the answer", () => {
|
|
122
|
+
const html = renderToStaticMarkup(
|
|
123
|
+
<TurnView
|
|
124
|
+
turn={{
|
|
125
|
+
question: "Explain simply",
|
|
126
|
+
explainAssessment: 2,
|
|
127
|
+
status: "done",
|
|
128
|
+
timeline: [],
|
|
129
|
+
error: null,
|
|
130
|
+
diagnosis: { report: "The long answer is in Findings" } as Diagnosis,
|
|
131
|
+
}}
|
|
132
|
+
onViewExplanation={noop}
|
|
133
|
+
/>,
|
|
134
|
+
);
|
|
135
|
+
expect(html).toContain("Plain-language explanation");
|
|
136
|
+
expect(html).toContain("View in Findings");
|
|
137
|
+
expect(html).not.toContain("The long answer is in Findings");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("appendThinking", () => {
|
|
142
|
+
it("separates adjacent bold reasoning headings without changing ordinary chunks", () => {
|
|
143
|
+
const first = appendThinking([], "**Inspecting workload**", false);
|
|
144
|
+
const headings = appendThinking(first, "**Reading crash logs**", false);
|
|
145
|
+
const chunks = appendThinking(headings, " and events", false);
|
|
146
|
+
|
|
147
|
+
expect(headings).toEqual([
|
|
148
|
+
{
|
|
149
|
+
kind: "thinking",
|
|
150
|
+
text: "**Inspecting workload**",
|
|
151
|
+
animate: false,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
kind: "thinking",
|
|
155
|
+
text: "**Reading crash logs**",
|
|
156
|
+
animate: false,
|
|
157
|
+
},
|
|
158
|
+
]);
|
|
159
|
+
expect(chunks[1]).toMatchObject({
|
|
160
|
+
text: "**Reading crash logs** and events",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(
|
|
164
|
+
appendThinking([], "**Checking logs**\n**Checking events**", false),
|
|
165
|
+
).toMatchObject([
|
|
166
|
+
{ kind: "thinking", text: "**Checking logs**" },
|
|
167
|
+
{ kind: "thinking", text: "**Checking events**" },
|
|
168
|
+
]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("deduplicates an exact repeated reasoning beat", () => {
|
|
172
|
+
const first = appendThinking([], "**Inspecting workload**", false);
|
|
173
|
+
expect(appendThinking(first, "**Inspecting workload**", false)).toEqual(
|
|
174
|
+
first,
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("preserves repeated ordinary lines identically in live chunks and replay", () => {
|
|
179
|
+
const line = "The retry produced the same timeout.\n";
|
|
180
|
+
const replay = appendThinking([], line + line, false);
|
|
181
|
+
const live = appendThinking(appendThinking([], line, true), line, true);
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
live.map((item) => (item.kind === "thinking" ? item.text : undefined)),
|
|
185
|
+
).toEqual(
|
|
186
|
+
replay.map((item) => (item.kind === "thinking" ? item.text : undefined)),
|
|
187
|
+
);
|
|
188
|
+
expect(live).toMatchObject([
|
|
189
|
+
{
|
|
190
|
+
kind: "thinking",
|
|
191
|
+
text: line + line,
|
|
192
|
+
animate: true,
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("upsertTool", () => {
|
|
199
|
+
const running: DiagnoseStep = {
|
|
200
|
+
id: "tool-1",
|
|
201
|
+
tool: "get_resource",
|
|
202
|
+
status: "running",
|
|
203
|
+
summary: "kind=deployment namespace=dev name=api",
|
|
204
|
+
};
|
|
205
|
+
const completion: DiagnoseStep = {
|
|
206
|
+
id: running.id,
|
|
207
|
+
tool: "",
|
|
208
|
+
status: "done",
|
|
209
|
+
ms: 83,
|
|
210
|
+
result: '{"readyReplicas":1}',
|
|
211
|
+
evidenceRef: "ev-1",
|
|
212
|
+
radarEvidence: true,
|
|
213
|
+
isError: false,
|
|
214
|
+
truncated: false,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
it("appends a new call with its identity and input without changing earlier entries", () => {
|
|
218
|
+
const previous: TimelineItem[] = [
|
|
219
|
+
{ kind: "thinking", text: "Checking the workload" },
|
|
220
|
+
];
|
|
221
|
+
const next = upsertTool(previous, running);
|
|
222
|
+
|
|
223
|
+
expect(next).toEqual([
|
|
224
|
+
previous[0],
|
|
225
|
+
{ kind: "tool", ...running, animate: true },
|
|
226
|
+
]);
|
|
227
|
+
expect(next[0]).toBe(previous[0]);
|
|
228
|
+
expect(previous).toHaveLength(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it.each([undefined, ""])(
|
|
232
|
+
"merges completion in place, preserving running input when summary is %s",
|
|
233
|
+
(summary) => {
|
|
234
|
+
const previous = upsertTool([], running, false);
|
|
235
|
+
previous.push({ kind: "tool", ...running, id: "tool-2", animate: false });
|
|
236
|
+
const next = upsertTool(previous, { ...completion, summary }, false);
|
|
237
|
+
|
|
238
|
+
expect(next).toEqual([
|
|
239
|
+
{
|
|
240
|
+
kind: "tool",
|
|
241
|
+
...completion,
|
|
242
|
+
tool: running.tool,
|
|
243
|
+
summary: running.summary,
|
|
244
|
+
animate: false,
|
|
245
|
+
},
|
|
246
|
+
previous[1],
|
|
247
|
+
]);
|
|
248
|
+
expect(next[1]).toBe(previous[1]);
|
|
249
|
+
expect(previous[0]).toEqual({ kind: "tool", ...running, animate: false });
|
|
250
|
+
expect(upsertTool(next, completion, false)).toEqual(next);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
it("replaces result and provenance metadata, including explicit false values", () => {
|
|
255
|
+
const previous = upsertTool([], completion, false);
|
|
256
|
+
const failed: DiagnoseStep = {
|
|
257
|
+
...completion,
|
|
258
|
+
result: "Permission denied",
|
|
259
|
+
evidenceRef: "ev-2",
|
|
260
|
+
radarEvidence: false,
|
|
261
|
+
isError: true,
|
|
262
|
+
truncated: true,
|
|
263
|
+
};
|
|
264
|
+
const next = upsertTool(previous, failed, false);
|
|
265
|
+
|
|
266
|
+
expect(next).toEqual([{ kind: "tool", ...failed, animate: false }]);
|
|
267
|
+
expect(upsertTool(next, completion, false)).toEqual(previous);
|
|
268
|
+
expect(previous[0]).toMatchObject(completion);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it.each([
|
|
272
|
+
{ arrival: false, update: false, expected: false },
|
|
273
|
+
{ arrival: false, update: true, expected: true },
|
|
274
|
+
{ arrival: true, update: false, expected: true },
|
|
275
|
+
{ arrival: true, update: true, expected: true },
|
|
276
|
+
])(
|
|
277
|
+
"keeps animation $expected for arrival=$arrival and update=$update",
|
|
278
|
+
({ arrival, update, expected }) => {
|
|
279
|
+
const previous = upsertTool([], running, arrival);
|
|
280
|
+
const next = upsertTool(previous, completion, update);
|
|
281
|
+
|
|
282
|
+
expect(previous[0]).toMatchObject({ animate: arrival });
|
|
283
|
+
expect(next[0]).toMatchObject({ animate: expected });
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("Timeline reasoning density", () => {
|
|
289
|
+
const reasoning = {
|
|
290
|
+
kind: "thinking" as const,
|
|
291
|
+
text: "A long reasoning beat that may occupy several lines in the activity pane.",
|
|
292
|
+
animate: false,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
it("clamps completed reasoning to two lines", () => {
|
|
296
|
+
const html = renderToStaticMarkup(
|
|
297
|
+
<Timeline items={[reasoning]} running={false} />,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
expect(html).toContain("height:47px");
|
|
301
|
+
expect(html).toContain(
|
|
302
|
+
"transition-[height] duration-200 ease-out motion-reduce:transition-none",
|
|
303
|
+
);
|
|
304
|
+
expect(html).not.toContain("animate-transcript-enter");
|
|
305
|
+
expect(html).not.toContain("Show reasoning");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("keeps the final live reasoning beat unclamped", () => {
|
|
309
|
+
const html = renderToStaticMarkup(<Timeline items={[reasoning]} running />);
|
|
310
|
+
|
|
311
|
+
expect(html).not.toContain("height:47px");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("clamps an earlier reasoning beat once a live tool follows it", () => {
|
|
315
|
+
const html = renderToStaticMarkup(
|
|
316
|
+
<Timeline
|
|
317
|
+
items={[
|
|
318
|
+
reasoning,
|
|
319
|
+
{
|
|
320
|
+
kind: "tool",
|
|
321
|
+
id: "tool-1",
|
|
322
|
+
tool: "get_resource",
|
|
323
|
+
status: "running",
|
|
324
|
+
animate: false,
|
|
325
|
+
},
|
|
326
|
+
]}
|
|
327
|
+
running
|
|
328
|
+
/>,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(html).toContain("height:47px");
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
8
335
|
function renderAgent(
|
|
9
336
|
agent: string,
|
|
10
337
|
profiles: ExecutionProfile[],
|
|
@@ -57,7 +384,9 @@ describe("AgentControls execution profile explanation", () => {
|
|
|
57
384
|
"safeguarded",
|
|
58
385
|
);
|
|
59
386
|
expect(html).toContain("built-in tools are disabled");
|
|
60
|
-
expect(html).toContain(
|
|
387
|
+
expect(html).toContain(
|
|
388
|
+
"settings, hooks, and CLAUDE.md instructions still apply",
|
|
389
|
+
);
|
|
61
390
|
expect(html).toContain("Your claude setup");
|
|
62
391
|
expect(html).not.toContain("other agent configuration is excluded");
|
|
63
392
|
});
|
|
@@ -170,3 +499,535 @@ describe("ConsentCard error", () => {
|
|
|
170
499
|
expect(html).not.toContain('role="alert"');
|
|
171
500
|
});
|
|
172
501
|
});
|
|
502
|
+
|
|
503
|
+
describe("ResultCard conclusion states", () => {
|
|
504
|
+
const diagnosis = (patch: Partial<Diagnosis>): Diagnosis => ({
|
|
505
|
+
rootCause: "",
|
|
506
|
+
report: "",
|
|
507
|
+
remediation: [],
|
|
508
|
+
...patch,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const conclusionCases: Array<[Diagnosis, string]> = [
|
|
512
|
+
[diagnosis({ rootCause: "The image does not exist." }), "Likely cause"],
|
|
513
|
+
[
|
|
514
|
+
diagnosis({ healthy: true, report: "The workload is ready." }),
|
|
515
|
+
"No problem found in checked evidence",
|
|
516
|
+
],
|
|
517
|
+
[diagnosis({ inconclusive: true }), "Couldn't determine"],
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
it.each(conclusionCases)(
|
|
521
|
+
"preserves the outcome-specific heading %#",
|
|
522
|
+
(value, heading) => {
|
|
523
|
+
const html = renderToStaticMarkup(<ResultCard diagnosis={value} />);
|
|
524
|
+
expect(html).toContain(heading);
|
|
525
|
+
expect(html).not.toContain("Verdict");
|
|
526
|
+
},
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
it("qualifies a healthy assessment when structured evidence coverage is absent", () => {
|
|
530
|
+
const html = renderToStaticMarkup(
|
|
531
|
+
<ResultCard
|
|
532
|
+
diagnosis={diagnosis({
|
|
533
|
+
healthy: true,
|
|
534
|
+
report: "The workload appears ready.",
|
|
535
|
+
})}
|
|
536
|
+
coverageLimited
|
|
537
|
+
/>,
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
expect(html).toContain("No problem identified in available evidence");
|
|
541
|
+
expect(html).toContain("Evidence coverage is limited.");
|
|
542
|
+
expect(html).not.toContain("Some evidence could not be summarized");
|
|
543
|
+
expect(html).toContain("border-amber-500/30");
|
|
544
|
+
expect(html).toContain("text-amber-500");
|
|
545
|
+
expect(html).not.toContain("border-emerald-500/30");
|
|
546
|
+
expect(html).not.toContain("text-emerald-500");
|
|
547
|
+
expect(html).not.toContain(">No active problems found</div>");
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("reserves the verified healthy treatment for an adequately covered all-clear", () => {
|
|
551
|
+
const html = renderToStaticMarkup(
|
|
552
|
+
<ResultCard
|
|
553
|
+
diagnosis={diagnosis({
|
|
554
|
+
healthy: true,
|
|
555
|
+
report: "The workload is ready.",
|
|
556
|
+
})}
|
|
557
|
+
/>,
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
expect(html).toContain("No problem found in checked evidence");
|
|
561
|
+
expect(html).toContain("border-emerald-500/30");
|
|
562
|
+
expect(html).toContain("text-emerald-500");
|
|
563
|
+
expect(html).not.toContain("No problem identified in available evidence");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("does not overstate the rank of evidence that conflicts with an all-clear", () => {
|
|
567
|
+
const html = renderToStaticMarkup(
|
|
568
|
+
<ResultCard
|
|
569
|
+
diagnosis={diagnosis({
|
|
570
|
+
healthy: true,
|
|
571
|
+
report: "The workload appears ready.",
|
|
572
|
+
})}
|
|
573
|
+
evidenceConflict
|
|
574
|
+
/>,
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
expect(html).toContain("Assessment conflicts with captured evidence");
|
|
578
|
+
expect(html).toContain("Radar also captured evidence of an active problem");
|
|
579
|
+
expect(html).not.toContain("Key evidence");
|
|
580
|
+
expect(html).toContain("border-amber-500/40");
|
|
581
|
+
expect(html).not.toContain("border-emerald-500/30");
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("keeps a follow-up framed as an answer rather than a new conclusion", () => {
|
|
585
|
+
const html = renderToStaticMarkup(
|
|
586
|
+
<ResultCard
|
|
587
|
+
diagnosis={diagnosis({ report: "The restart count is unchanged." })}
|
|
588
|
+
followup
|
|
589
|
+
/>,
|
|
590
|
+
);
|
|
591
|
+
expect(html).toContain("Answer");
|
|
592
|
+
expect(html).not.toContain("Likely cause");
|
|
593
|
+
expect(html).not.toContain("Conclusion");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it.each([
|
|
597
|
+
diagnosis({ healthy: true, report: "It is healthy right now." }),
|
|
598
|
+
diagnosis({
|
|
599
|
+
inconclusive: true,
|
|
600
|
+
report: "I cannot tell from the available checks.",
|
|
601
|
+
}),
|
|
602
|
+
])("keeps health-flagged ordinary follow-ups conversational", (value) => {
|
|
603
|
+
const html = renderToStaticMarkup(
|
|
604
|
+
<ResultCard diagnosis={value} followup />,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
expect(html).toContain("Answer");
|
|
608
|
+
expect(html).not.toContain("No active problems found");
|
|
609
|
+
expect(html).not.toContain("Couldn't determine");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("keeps recovery guidance when Findings suppresses the AI disclaimer", () => {
|
|
613
|
+
const html = renderToStaticMarkup(
|
|
614
|
+
<ResultCard
|
|
615
|
+
diagnosis={diagnosis({
|
|
616
|
+
inconclusive: true,
|
|
617
|
+
report: "The available evidence is ambiguous.",
|
|
618
|
+
})}
|
|
619
|
+
showDisclaimer={false}
|
|
620
|
+
/>,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
expect(html).toContain("Try a follow-up with more context");
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("can place the conclusion before evidence and actions after it", () => {
|
|
627
|
+
const value = diagnosis({
|
|
628
|
+
rootCause: "The image does not exist.",
|
|
629
|
+
remediation: ["Push the image, then restart the rollout."],
|
|
630
|
+
recommendedIndex: 1,
|
|
631
|
+
});
|
|
632
|
+
const conclusion = renderToStaticMarkup(
|
|
633
|
+
<ResultCard diagnosis={value} section="conclusion" />,
|
|
634
|
+
);
|
|
635
|
+
const actions = renderToStaticMarkup(
|
|
636
|
+
<ResultCard diagnosis={value} section="actions" />,
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
expect(conclusion).toContain(value.rootCause);
|
|
640
|
+
expect(conclusion).not.toContain("Remediation");
|
|
641
|
+
expect(actions).toContain(value.remediation![0]);
|
|
642
|
+
expect(actions).not.toContain("Likely cause");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("does not stagger remediation rows after the result card arrives", () => {
|
|
646
|
+
const html = renderToStaticMarkup(
|
|
647
|
+
<ResultCard
|
|
648
|
+
diagnosis={diagnosis({
|
|
649
|
+
rootCause: "The image does not exist.",
|
|
650
|
+
remediation: ["Push the image.", "Restart the rollout."],
|
|
651
|
+
recommendedIndex: 1,
|
|
652
|
+
})}
|
|
653
|
+
/>,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
expect(html).not.toContain("animation-delay");
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("keeps only the recommended action in the first Findings scan", () => {
|
|
660
|
+
const html = renderToStaticMarkup(
|
|
661
|
+
<ResultCard
|
|
662
|
+
diagnosis={diagnosis({
|
|
663
|
+
rootCause: "The image does not exist.",
|
|
664
|
+
remediation: [
|
|
665
|
+
"Inspect the registry.",
|
|
666
|
+
"Push the missing image.",
|
|
667
|
+
"Restart the rollout.",
|
|
668
|
+
],
|
|
669
|
+
recommendedIndex: 2,
|
|
670
|
+
})}
|
|
671
|
+
section="actions"
|
|
672
|
+
compactActions
|
|
673
|
+
/>,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
expect(html).toContain("Push the missing image.");
|
|
677
|
+
expect(html).not.toContain("Inspect the registry.");
|
|
678
|
+
expect(html).not.toContain("Restart the rollout.");
|
|
679
|
+
expect(html).toContain("Show 2 more steps");
|
|
680
|
+
expect(html).toContain('aria-expanded="false"');
|
|
681
|
+
expect(html).toContain("grid-template-rows:0fr");
|
|
682
|
+
expect(html).toContain("motion-reduce:transition-none");
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("groups assessment controls in one footer without duplicating actions", () => {
|
|
686
|
+
const html = renderToStaticMarkup(
|
|
687
|
+
<ResultCard
|
|
688
|
+
diagnosis={diagnosis({
|
|
689
|
+
rootCause: "The image is missing.",
|
|
690
|
+
report: "Registry details.",
|
|
691
|
+
})}
|
|
692
|
+
section="conclusion"
|
|
693
|
+
explanation={{ status: "idle", onGenerate: noop }}
|
|
694
|
+
assessmentAction={<button>Next steps</button>}
|
|
695
|
+
/>,
|
|
696
|
+
);
|
|
697
|
+
const footer = html.slice(html.indexOf("data-assessment-actions"));
|
|
698
|
+
expect(footer).toContain("Full analysis");
|
|
699
|
+
expect(footer).toContain("Explain simply");
|
|
700
|
+
expect(footer).toContain("Next steps");
|
|
701
|
+
expect(html.match(/Explain simply/g)).toHaveLength(1);
|
|
702
|
+
expect(html.match(/Next steps/g)).toHaveLength(1);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("offers apply only for the explicitly recommended remediation", () => {
|
|
706
|
+
const html = renderToStaticMarkup(
|
|
707
|
+
<ResultCard
|
|
708
|
+
diagnosis={diagnosis({
|
|
709
|
+
rootCause: "The image does not exist.",
|
|
710
|
+
remediation: [
|
|
711
|
+
"Inspect the registry.",
|
|
712
|
+
"Push the missing image.",
|
|
713
|
+
"Restart the rollout.",
|
|
714
|
+
],
|
|
715
|
+
recommendedIndex: 2,
|
|
716
|
+
})}
|
|
717
|
+
section="actions"
|
|
718
|
+
onApply={() => undefined}
|
|
719
|
+
/>,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
expect(html.match(/Apply…/g)).toHaveLength(1);
|
|
723
|
+
expect(html).toContain("Push the missing image.");
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe("TurnView tool outcome truth", () => {
|
|
728
|
+
function turn(isError: boolean | undefined): Turn {
|
|
729
|
+
return {
|
|
730
|
+
timeline: [
|
|
731
|
+
{
|
|
732
|
+
kind: "tool",
|
|
733
|
+
id: "tool-1",
|
|
734
|
+
tool: "diagnose",
|
|
735
|
+
status: "done",
|
|
736
|
+
isError,
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
diagnosis: null,
|
|
740
|
+
error: null,
|
|
741
|
+
status: "running",
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
it("uses success color only for producer-confirmed success", () => {
|
|
746
|
+
const success = renderToStaticMarkup(<TurnView turn={turn(false)} />);
|
|
747
|
+
const unknown = renderToStaticMarkup(<TurnView turn={turn(undefined)} />);
|
|
748
|
+
|
|
749
|
+
expect(success).toContain("text-emerald-400");
|
|
750
|
+
expect(unknown).toContain("text-theme-text-tertiary");
|
|
751
|
+
expect(unknown).not.toContain("text-emerald-400");
|
|
752
|
+
expect(unknown).toContain("lucide-circle-question-mark");
|
|
753
|
+
expect(unknown).not.toContain("lucide-circle-check-big");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("renders a producer-confirmed failure as an error", () => {
|
|
757
|
+
const html = renderToStaticMarkup(<TurnView turn={turn(true)} />);
|
|
758
|
+
expect(html).toContain("Tool failed");
|
|
759
|
+
expect(html).toContain("text-red-400");
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it("does not expose an inert button when a tool has no detail", () => {
|
|
763
|
+
const html = renderToStaticMarkup(<TurnView turn={turn(false)} />);
|
|
764
|
+
expect(html).not.toContain("<button");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("visibly marks a tool row reached from Findings programmatically", () => {
|
|
768
|
+
const html = renderToStaticMarkup(
|
|
769
|
+
<TurnView
|
|
770
|
+
turn={turn(false)}
|
|
771
|
+
turnIndex={0}
|
|
772
|
+
evidenceStepIds={new Set(["tool-1"])}
|
|
773
|
+
onViewEvidence={noop}
|
|
774
|
+
/>,
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
expect(html).toContain("focus:ring-2");
|
|
778
|
+
expect(html).not.toContain("focus-visible:ring-2");
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("connects a detail disclosure to the region it controls", () => {
|
|
782
|
+
const detailed = turn(false);
|
|
783
|
+
const item = detailed.timeline[0];
|
|
784
|
+
if (item.kind === "tool") item.summary = '{"ready":true}';
|
|
785
|
+
const html = renderToStaticMarkup(<TurnView turn={detailed} />);
|
|
786
|
+
const controlled = html.match(/aria-controls="([^"]+)"/)?.[1];
|
|
787
|
+
|
|
788
|
+
expect(controlled).toBeTruthy();
|
|
789
|
+
expect(html).toContain(`id="${controlled}"`);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("opens and names the exact tool result requested from Findings", () => {
|
|
793
|
+
const detailed = turn(false);
|
|
794
|
+
const item = detailed.timeline[0];
|
|
795
|
+
if (item.kind === "tool") {
|
|
796
|
+
item.summary = '{"kind":"Deployment"}';
|
|
797
|
+
}
|
|
798
|
+
const sourceId = investigationEvidenceSourceId(0, "tool-1");
|
|
799
|
+
const html = renderToStaticMarkup(
|
|
800
|
+
<TurnView
|
|
801
|
+
turn={detailed}
|
|
802
|
+
turnIndex={0}
|
|
803
|
+
sourceRevealRequest={{ sourceId, requestId: 1 }}
|
|
804
|
+
/>,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
expect(html).toContain('aria-expanded="true"');
|
|
808
|
+
expect(html).toContain('role="group"');
|
|
809
|
+
expect(html).toContain('aria-label="Diagnose tool completed"');
|
|
810
|
+
expect(html).toContain('aria-label="Tool arguments"');
|
|
811
|
+
expect(html.match(/kind=Deployment/g)).toHaveLength(1);
|
|
812
|
+
expect(html).not.toContain("<details");
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("offers large arguments once above the result without a duplicate header preview", () => {
|
|
816
|
+
const detailed = turn(false);
|
|
817
|
+
const item = detailed.timeline[0];
|
|
818
|
+
if (item.kind === "tool") {
|
|
819
|
+
item.summary = JSON.stringify({ query: "long-query-".repeat(40) });
|
|
820
|
+
item.result = '{"ok":true}';
|
|
821
|
+
}
|
|
822
|
+
const html = renderToStaticMarkup(
|
|
823
|
+
<ThemeProvider>
|
|
824
|
+
<TurnView
|
|
825
|
+
turn={detailed}
|
|
826
|
+
turnIndex={0}
|
|
827
|
+
sourceRevealRequest={{
|
|
828
|
+
sourceId: investigationEvidenceSourceId(0, "tool-1"),
|
|
829
|
+
requestId: 1,
|
|
830
|
+
}}
|
|
831
|
+
/>
|
|
832
|
+
</ThemeProvider>,
|
|
833
|
+
);
|
|
834
|
+
expect(html).toContain("Show arguments");
|
|
835
|
+
expect(html.indexOf("Show arguments")).toBeLessThan(
|
|
836
|
+
html.indexOf("Original result"),
|
|
837
|
+
);
|
|
838
|
+
expect(html).not.toContain("query=long-query");
|
|
839
|
+
expect(html).not.toContain("<details");
|
|
840
|
+
expect(html).toContain("transition-[grid-template-rows]");
|
|
841
|
+
expect(html).toContain('aria-expanded="false"');
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("renders replayed activity and its conclusion without arrival motion", () => {
|
|
845
|
+
const replayed = turn(false);
|
|
846
|
+
replayed.status = "done";
|
|
847
|
+
replayed.timeline[0].animate = false;
|
|
848
|
+
replayed.diagnosis = {
|
|
849
|
+
healthy: true,
|
|
850
|
+
rootCause: "",
|
|
851
|
+
report: "The workload is ready.",
|
|
852
|
+
remediation: [],
|
|
853
|
+
};
|
|
854
|
+
replayed.animateResult = false;
|
|
855
|
+
|
|
856
|
+
const html = renderToStaticMarkup(<TurnView turn={replayed} />);
|
|
857
|
+
expect(html).not.toContain("animate-transcript-enter");
|
|
858
|
+
expect(html).not.toContain("animate-result-in");
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("frames automatic verification as system activity, not user chat", () => {
|
|
862
|
+
const verification = turn(false);
|
|
863
|
+
verification.question = "internal verification prompt";
|
|
864
|
+
verification.verify = true;
|
|
865
|
+
|
|
866
|
+
const html = renderToStaticMarkup(<TurnView turn={verification} />);
|
|
867
|
+
expect(html).toContain("Automatic verification");
|
|
868
|
+
expect(html).toContain("Re-checking after apply");
|
|
869
|
+
expect(html).not.toContain("internal verification prompt");
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it("renders an unknown apply prominently and offers current-state verification", () => {
|
|
873
|
+
const apply = turn(false);
|
|
874
|
+
apply.apply = true;
|
|
875
|
+
apply.status = "error";
|
|
876
|
+
apply.applyOutcome = "unknown";
|
|
877
|
+
apply.error =
|
|
878
|
+
"The apply stopped before Radar could confirm whether the change completed.";
|
|
879
|
+
|
|
880
|
+
const html = renderToStaticMarkup(
|
|
881
|
+
<TurnView turn={apply} onCheckStatus={noop} />,
|
|
882
|
+
);
|
|
883
|
+
expect(html).toContain("Outcome unknown");
|
|
884
|
+
expect(html).toContain("border-amber-500/40");
|
|
885
|
+
expect(html).toContain(
|
|
886
|
+
"Check the current state before trying to apply this change again.",
|
|
887
|
+
);
|
|
888
|
+
expect(html).toContain("Check current status");
|
|
889
|
+
expect(html).toContain("could confirm whether the change completed");
|
|
890
|
+
expect(html).not.toContain(">Applied</div>");
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("reserves green Applied for a producer-confirmed mutation", () => {
|
|
894
|
+
const apply = turn(undefined);
|
|
895
|
+
apply.apply = true;
|
|
896
|
+
apply.status = "done";
|
|
897
|
+
apply.applyOutcome = "confirmed";
|
|
898
|
+
apply.diagnosis = {
|
|
899
|
+
rootCause: "",
|
|
900
|
+
report: "Deployment updated.",
|
|
901
|
+
remediation: [],
|
|
902
|
+
};
|
|
903
|
+
|
|
904
|
+
const html = renderToStaticMarkup(<TurnView turn={apply} />);
|
|
905
|
+
expect(html).toContain("Applied");
|
|
906
|
+
expect(html).toContain("border-emerald-500/30");
|
|
907
|
+
expect(html).toContain("Radar confirmed the change was applied");
|
|
908
|
+
expect(html).not.toContain("Outcome unknown");
|
|
909
|
+
expect(html).not.toContain("Not applied");
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("renders an authoritative apply failure as Not applied and never green", () => {
|
|
913
|
+
const apply = turn(undefined);
|
|
914
|
+
apply.apply = true;
|
|
915
|
+
apply.status = "error";
|
|
916
|
+
apply.applyOutcome = "failed";
|
|
917
|
+
apply.error = "The write tool rejected the change.";
|
|
918
|
+
|
|
919
|
+
const html = renderToStaticMarkup(
|
|
920
|
+
<TurnView turn={apply} onCheckStatus={noop} />,
|
|
921
|
+
);
|
|
922
|
+
expect(html).toContain("Not applied");
|
|
923
|
+
expect(html).toContain("border-red-500/30");
|
|
924
|
+
expect(html).toContain("The write tool rejected the change.");
|
|
925
|
+
expect(html).not.toContain("border-emerald-500/30");
|
|
926
|
+
expect(html).not.toContain("Check current status");
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("keeps confirmed mutation truth when the agent report is incomplete", () => {
|
|
930
|
+
const apply = turn(undefined);
|
|
931
|
+
apply.apply = true;
|
|
932
|
+
apply.status = "error";
|
|
933
|
+
apply.applyOutcome = "confirmed";
|
|
934
|
+
apply.error =
|
|
935
|
+
"A Radar write tool confirmed the mutation, but the agent ended before completing its report.";
|
|
936
|
+
|
|
937
|
+
const html = renderToStaticMarkup(<TurnView turn={apply} />);
|
|
938
|
+
expect(html).toContain("Applied");
|
|
939
|
+
expect(html).toContain("border-emerald-500/30");
|
|
940
|
+
expect(html).toContain("the agent report is incomplete");
|
|
941
|
+
expect(html).toContain("confirmed the mutation");
|
|
942
|
+
expect(html).not.toContain("Not applied");
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it("fails closed when an apply terminal event has no outcome metadata", () => {
|
|
946
|
+
const apply = turn(undefined);
|
|
947
|
+
apply.apply = true;
|
|
948
|
+
apply.status = "done";
|
|
949
|
+
apply.diagnosis = {
|
|
950
|
+
rootCause: "",
|
|
951
|
+
report: "The agent says the deployment was updated.",
|
|
952
|
+
remediation: [],
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const html = renderToStaticMarkup(<TurnView turn={apply} />);
|
|
956
|
+
expect(html).toContain("Outcome unknown");
|
|
957
|
+
expect(html).toContain("border-amber-500/40");
|
|
958
|
+
expect(html).not.toContain("border-emerald-500/30");
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it.each([
|
|
962
|
+
{ healthy: true, report: "It is healthy right now." },
|
|
963
|
+
{
|
|
964
|
+
inconclusive: true,
|
|
965
|
+
report: "I cannot tell from the available checks.",
|
|
966
|
+
},
|
|
967
|
+
])(
|
|
968
|
+
"does not promote a health-flagged question into a conclusion",
|
|
969
|
+
(patch) => {
|
|
970
|
+
const followup = turn(false);
|
|
971
|
+
followup.question = "Is it healthy now?";
|
|
972
|
+
followup.status = "done";
|
|
973
|
+
followup.diagnosis = {
|
|
974
|
+
rootCause: "",
|
|
975
|
+
remediation: [],
|
|
976
|
+
...patch,
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const html = renderToStaticMarkup(<TurnView turn={followup} />);
|
|
980
|
+
expect(html).toContain("Answer");
|
|
981
|
+
expect(html).not.toContain("No active problems found");
|
|
982
|
+
expect(html).not.toContain("Couldn't determine");
|
|
983
|
+
},
|
|
984
|
+
);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
describe("assessment provenance disclosure", () => {
|
|
988
|
+
it.each([
|
|
989
|
+
{ rootCause: "Missing configuration", remediation: [] },
|
|
990
|
+
{ healthy: true, remediation: [] },
|
|
991
|
+
{ inconclusive: true, remediation: [] },
|
|
992
|
+
])("offers Assessment details with sources alone: %j", (diagnosis) => {
|
|
993
|
+
const html = renderToStaticMarkup(
|
|
994
|
+
<ResultCard
|
|
995
|
+
diagnosis={{ ...diagnosis, report: "" } as Diagnosis}
|
|
996
|
+
section="conclusion"
|
|
997
|
+
assessmentSources={<div>SOURCE_ONLY</div>}
|
|
998
|
+
explanation={{ status: "running" }}
|
|
999
|
+
/>,
|
|
1000
|
+
);
|
|
1001
|
+
expect(html).toContain("Assessment details");
|
|
1002
|
+
expect(html).not.toContain(">Full analysis<");
|
|
1003
|
+
});
|
|
1004
|
+
it("keeps separate assessments' exact source bindings without Evidence DOM anchors", () => {
|
|
1005
|
+
const renderSources = (stepId: string) =>
|
|
1006
|
+
renderToStaticMarkup(
|
|
1007
|
+
<AssessmentSources
|
|
1008
|
+
resolution={
|
|
1009
|
+
{
|
|
1010
|
+
status: "linked",
|
|
1011
|
+
links: [
|
|
1012
|
+
{
|
|
1013
|
+
source: {
|
|
1014
|
+
id: stepId,
|
|
1015
|
+
tool: "search",
|
|
1016
|
+
args: JSON.stringify({ query: stepId }),
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
],
|
|
1020
|
+
} as Parameters<typeof AssessmentSources>[0]["resolution"]
|
|
1021
|
+
}
|
|
1022
|
+
onViewSource={noop}
|
|
1023
|
+
/>,
|
|
1024
|
+
);
|
|
1025
|
+
expect(renderSources("initial-source")).toContain("initial-source");
|
|
1026
|
+
expect(renderSources("initial-source")).not.toContain("verified-source");
|
|
1027
|
+
expect(renderSources("verified-source")).toContain("verified-source");
|
|
1028
|
+
expect(renderSources("verified-source")).not.toContain("initial-source");
|
|
1029
|
+
expect(renderSources("initial-source")).not.toContain(
|
|
1030
|
+
'id="investigation-evidence-',
|
|
1031
|
+
);
|
|
1032
|
+
});
|
|
1033
|
+
});
|