@skyhook-io/radar-app 1.14.0 → 1.14.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 +6 -4
- package/src/api/client.ts +3 -0
- package/src/api/diagnose.ts +31 -1
- package/src/components/CloudConnectFlow.tsx +173 -13
- package/src/components/CloudFunnelButton.tsx +4 -2
- package/src/components/ConnectionErrorView.tsx +9 -10
- package/src/components/DebugOverlay.tsx +10 -6
- package/src/components/cloudConnectHandoff.test.ts +109 -4
- package/src/components/cloudConnectHandoff.ts +154 -2
- package/src/components/curl/ServiceCurlButton.tsx +19 -26
- package/src/components/diagnose/AgentCase.tsx +18 -5
- package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
- package/src/components/diagnose/AnalysisStory.tsx +564 -0
- package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
- package/src/components/diagnose/DiagnoseContext.tsx +25 -8
- package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
- package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
- package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
- package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
- package/src/components/diagnose/InvestigationView.tsx +493 -529
- package/src/components/diagnose/investigationCase.test.tsx +267 -129
- package/src/components/diagnose/investigationCase.ts +122 -77
- package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +27 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +28 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +11 -2
- package/src/components/diagnose/investigationEvidence/identity.test.ts +25 -7
- package/src/components/diagnose/investigationEvidence/identity.ts +4 -4
- package/src/components/diagnose/investigationEvidence/observations.ts +24 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +36 -3
- package/src/components/diagnose/investigationEvidence/types.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
- package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
- package/src/components/diagnose/investigationState.test.ts +316 -58
- package/src/components/diagnose/investigationState.ts +257 -44
- package/src/components/diagnose/investigationStory.test.ts +161 -0
- package/src/components/diagnose/investigationStory.ts +207 -0
- package/src/components/diagnose/parts.test.tsx +9 -6
- package/src/components/diagnose/parts.tsx +977 -193
- package/src/components/diagnose/storyParts.test.tsx +426 -0
- package/src/components/diagnose/toolCallLabel.test.ts +51 -0
- package/src/components/diagnose/toolCallLabel.ts +135 -0
- package/src/components/diagnose/useDisclosureReveal.ts +10 -11
- package/src/components/helm/HelmCompareRoute.tsx +18 -4
- package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
- package/src/components/helm/InstallWizard.tsx +8 -3
- package/src/components/home/MCPSetupDialog.tsx +15 -9
- package/src/components/portforward/PortForwardManager.tsx +6 -13
- package/src/components/resource/PrometheusChartsGrid.tsx +3 -3
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +3 -3
- package/src/components/resource/WorkloadMetricsSection.tsx +15 -21
- package/src/components/resources/PodFilePreview.tsx +3 -3
- package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
- package/src/components/settings/SettingsDialog.tsx +4 -2
- package/src/components/ui/CommandPalette.tsx +10 -6
- package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
- package/src/components/ui/Disclosure.tsx +47 -0
- package/src/components/ui/Markdown.tsx +23 -11
- package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
- package/src/components/ui/UpdateNotification.tsx +18 -2
- package/src/index.css +19 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Pure presentation decisions over the durable transcript and evidence projection.
|
|
2
2
|
// Keep React/DOM orchestration in InvestigationView; these rules have no UI runtime.
|
|
3
|
+
import { isDiagnosableWorkloadKind } from "./investigationEvidence/observations";
|
|
3
4
|
import { evidenceKindIsAdverse } from "./investigationEvidenceKinds";
|
|
4
5
|
import {
|
|
5
6
|
DiagnoseError,
|
|
@@ -273,32 +274,130 @@ export function investigationEvidenceInputsEqual(
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
/**
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
277
|
+
* Which turns are assessments — the ones Findings may show. The initial turn
|
|
278
|
+
* and explicit verifications always are; an ordinary question is one only
|
|
279
|
+
* when its verdict says so with `revisesAssessment` AND carries a complete
|
|
280
|
+
* verdict (a headline and a finding), because agents restate the root cause
|
|
281
|
+
* on most answers and a bare flag must never retire what the reader is
|
|
282
|
+
* looking at. The server enforces the same completeness rule; this mirrors it
|
|
283
|
+
* so a hosted backend that forgets cannot rewrite Findings by accident.
|
|
280
284
|
*/
|
|
281
|
-
export function
|
|
285
|
+
export function investigationIsAssessmentTurn(
|
|
286
|
+
turn: Pick<
|
|
287
|
+
Turn,
|
|
288
|
+
| "status"
|
|
289
|
+
| "apply"
|
|
290
|
+
| "explainAssessment"
|
|
291
|
+
| "question"
|
|
292
|
+
| "verify"
|
|
293
|
+
| "diagnosis"
|
|
294
|
+
>,
|
|
295
|
+
): boolean {
|
|
296
|
+
const dx = turn.diagnosis;
|
|
297
|
+
if (!dx || turn.status !== "done" || turn.apply || turn.explainAssessment)
|
|
298
|
+
return false;
|
|
299
|
+
const structured =
|
|
300
|
+
!!dx.rootCause ||
|
|
301
|
+
(dx.remediation?.length ?? 0) > 0 ||
|
|
302
|
+
!!dx.healthy ||
|
|
303
|
+
!!dx.inconclusive;
|
|
304
|
+
if (!structured) return false;
|
|
305
|
+
if (!turn.question || turn.verify) return true;
|
|
306
|
+
return (
|
|
307
|
+
dx.revisesAssessment === true &&
|
|
308
|
+
!!dx.summary &&
|
|
309
|
+
(!!dx.rootCause || !!dx.healthy || !!dx.inconclusive)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Later turns whose reads do not make the assessment "earlier": a question
|
|
315
|
+
* the agent answered under the story contract and marked as not revising it.
|
|
316
|
+
* The contract is only in force when the assessment itself carries a summary;
|
|
317
|
+
* older runs never asked, so every later read still counts as newer evidence.
|
|
318
|
+
*/
|
|
319
|
+
export function investigationSettledAnswerTurnIndexes(
|
|
282
320
|
turns: readonly Pick<
|
|
283
321
|
Turn,
|
|
284
|
-
|
|
322
|
+
| "status"
|
|
323
|
+
| "apply"
|
|
324
|
+
| "explainAssessment"
|
|
325
|
+
| "question"
|
|
326
|
+
| "verify"
|
|
327
|
+
| "diagnosis"
|
|
285
328
|
>[],
|
|
286
329
|
currentAssessmentIdx: number,
|
|
287
|
-
): number {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!diagnosis) continue;
|
|
330
|
+
): Set<number> {
|
|
331
|
+
const settled = new Set<number>();
|
|
332
|
+
const assessment = turns[currentAssessmentIdx]?.diagnosis;
|
|
333
|
+
if (!assessment?.summary) return settled;
|
|
334
|
+
turns.forEach((turn, index) => {
|
|
335
|
+
if (index <= currentAssessmentIdx) return;
|
|
294
336
|
if (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
337
|
+
turn.status === "done" &&
|
|
338
|
+
turn.diagnosis &&
|
|
339
|
+
!turn.apply &&
|
|
340
|
+
!turn.verify &&
|
|
341
|
+
!turn.explainAssessment &&
|
|
342
|
+
turn.diagnosis.revisesAssessment !== true
|
|
343
|
+
)
|
|
344
|
+
settled.add(index);
|
|
345
|
+
});
|
|
346
|
+
return settled;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function investigationAssessmentTurnIndexes(
|
|
350
|
+
turns: readonly Pick<
|
|
351
|
+
Turn,
|
|
352
|
+
| "status"
|
|
353
|
+
| "apply"
|
|
354
|
+
| "explainAssessment"
|
|
355
|
+
| "question"
|
|
356
|
+
| "verify"
|
|
357
|
+
| "diagnosis"
|
|
358
|
+
>[],
|
|
359
|
+
): number[] {
|
|
360
|
+
const indexes: number[] = [];
|
|
361
|
+
turns.forEach((turn, index) => {
|
|
362
|
+
if (investigationIsAssessmentTurn(turn)) indexes.push(index);
|
|
363
|
+
});
|
|
364
|
+
return indexes;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** The two coverage gaps that carry no limitation of their own, named so Still open can say which. */
|
|
368
|
+
export function investigationEvidenceCoverageGaps(
|
|
369
|
+
projection: Pick<InvestigationEvidenceProjection, "coverage"> & {
|
|
370
|
+
sources: readonly { id: string; tool: string; confirmedSuccess: boolean }[];
|
|
371
|
+
groups: readonly {
|
|
372
|
+
latest: {
|
|
373
|
+
relevance: "target" | "producer-related" | "broader";
|
|
374
|
+
source: { id: string };
|
|
375
|
+
};
|
|
376
|
+
}[];
|
|
377
|
+
},
|
|
378
|
+
targetKind?: string,
|
|
379
|
+
): { noEvidence: boolean; noTargetDiagnosis: boolean } {
|
|
380
|
+
// Diagnose covers workloads only; a target it cannot bundle (an HPA, a
|
|
381
|
+
// Service) is read through get_resource and issues, and that is complete.
|
|
382
|
+
if (targetKind !== undefined && !isDiagnosableWorkloadKind(targetKind))
|
|
383
|
+
return {
|
|
384
|
+
noEvidence: projection.coverage.projected === 0,
|
|
385
|
+
noTargetDiagnosis: false,
|
|
386
|
+
};
|
|
387
|
+
const completeDiagnosisSourceIds = new Set(
|
|
388
|
+
projection.sources
|
|
389
|
+
.filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
|
|
390
|
+
.map((source) => source.id),
|
|
391
|
+
);
|
|
392
|
+
const hasTargetDiagnosis = projection.groups.some(
|
|
393
|
+
(group) =>
|
|
394
|
+
group.latest.relevance !== "broader" &&
|
|
395
|
+
completeDiagnosisSourceIds.has(group.latest.source.id),
|
|
396
|
+
);
|
|
397
|
+
return {
|
|
398
|
+
noEvidence: projection.coverage.projected === 0,
|
|
399
|
+
noTargetDiagnosis: !hasTargetDiagnosis,
|
|
400
|
+
};
|
|
302
401
|
}
|
|
303
402
|
|
|
304
403
|
export function investigationEvidenceCoverageLimited(
|
|
@@ -318,21 +417,13 @@ export function investigationEvidenceCoverageLimited(
|
|
|
318
417
|
};
|
|
319
418
|
}[];
|
|
320
419
|
},
|
|
420
|
+
targetKind?: string,
|
|
321
421
|
): boolean {
|
|
322
|
-
const
|
|
323
|
-
projection.sources
|
|
324
|
-
.filter((source) => source.tool === "diagnose" && source.confirmedSuccess)
|
|
325
|
-
.map((source) => source.id),
|
|
326
|
-
);
|
|
327
|
-
const hasTargetDiagnosis = projection.groups.some(
|
|
328
|
-
(group) =>
|
|
329
|
-
group.latest.relevance !== "broader" &&
|
|
330
|
-
completeDiagnosisSourceIds.has(group.latest.source.id),
|
|
331
|
-
);
|
|
422
|
+
const gaps = investigationEvidenceCoverageGaps(projection, targetKind);
|
|
332
423
|
return (
|
|
333
424
|
projection.limitations.length > 0 ||
|
|
334
|
-
|
|
335
|
-
|
|
425
|
+
gaps.noEvidence ||
|
|
426
|
+
gaps.noTargetDiagnosis
|
|
336
427
|
);
|
|
337
428
|
}
|
|
338
429
|
|
|
@@ -353,11 +444,142 @@ interface HealthConflictGroup {
|
|
|
353
444
|
tier: "key" | "supporting" | "context" | "checked";
|
|
354
445
|
tone: string;
|
|
355
446
|
title?: string;
|
|
447
|
+
summary?: string;
|
|
448
|
+
/** The card's payload; an events card carries the events it summarises. */
|
|
449
|
+
data?: {
|
|
450
|
+
type: string;
|
|
451
|
+
events?: readonly { reason: string; message: string; type?: string }[];
|
|
452
|
+
};
|
|
356
453
|
/** Which turn captured this reading; a note cannot explain a later one. */
|
|
357
|
-
source?: { turnIndex: number };
|
|
454
|
+
source?: { turnIndex: number; id?: string };
|
|
358
455
|
};
|
|
359
456
|
}
|
|
360
457
|
|
|
458
|
+
// Group ids that are the same observation for the reader's question. Two
|
|
459
|
+
// routes: a shared display identity (a log stream read through two calls),
|
|
460
|
+
// and for events, the same warning recorded at two scopes about the
|
|
461
|
+
// investigated workload — its Deployment's event feed and its Pod's carry one
|
|
462
|
+
// readiness failure as two cards. The event's own reason and message decide,
|
|
463
|
+
// within one namespace and only among cards about this target; a name never
|
|
464
|
+
// does.
|
|
465
|
+
function healthTwinIds(
|
|
466
|
+
groups: readonly HealthConflictGroup[],
|
|
467
|
+
): (group: HealthConflictGroup) => Set<string> {
|
|
468
|
+
const byKey = new Map<string, Set<string>>();
|
|
469
|
+
const keysOf = (group: HealthConflictGroup): string[] => {
|
|
470
|
+
const keys: string[] = [];
|
|
471
|
+
if (group.identity) keys.push(`${group.kind}\u0000${group.identity}`);
|
|
472
|
+
if (
|
|
473
|
+
group.kind === "events" &&
|
|
474
|
+
group.latest.relevance !== "broader" &&
|
|
475
|
+
group.identity &&
|
|
476
|
+
group.latest.data?.type === "events"
|
|
477
|
+
) {
|
|
478
|
+
// The whole adverse set has to match: a Pod card with warning A is not
|
|
479
|
+
// the Deployment card that carries A and B.
|
|
480
|
+
const events = group.latest.data.events ?? [];
|
|
481
|
+
const warnings = events.filter(
|
|
482
|
+
(event) => event.type === undefined || event.type === "Warning",
|
|
483
|
+
);
|
|
484
|
+
const namespace = /^\S+\s+([^/\s]+)\//.exec(group.identity)?.[1];
|
|
485
|
+
if (warnings.length > 0 && namespace) {
|
|
486
|
+
const set = [
|
|
487
|
+
...new Set(
|
|
488
|
+
warnings.map(
|
|
489
|
+
(event) =>
|
|
490
|
+
`${event.reason.toLowerCase()}|${event.message.toLowerCase().replace(/\s+/g, " ").trim()}`,
|
|
491
|
+
),
|
|
492
|
+
),
|
|
493
|
+
]
|
|
494
|
+
.sort()
|
|
495
|
+
.join("\u0001");
|
|
496
|
+
keys.push(`events\u0000${namespace}\u0000${set}`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return keys;
|
|
500
|
+
};
|
|
501
|
+
for (const group of groups) {
|
|
502
|
+
if (!group.id) continue;
|
|
503
|
+
for (const key of keysOf(group)) {
|
|
504
|
+
const ids = byKey.get(key) ?? new Set<string>();
|
|
505
|
+
ids.add(group.id);
|
|
506
|
+
byKey.set(key, ids);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return (group) => {
|
|
510
|
+
const ids = new Set<string>([group.id ?? ""]);
|
|
511
|
+
for (const key of keysOf(group))
|
|
512
|
+
for (const id of byKey.get(key) ?? []) ids.add(id);
|
|
513
|
+
return ids;
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* One adverse Radar card on a healthy verdict, with the agent's position on
|
|
519
|
+
* it: it read the card as not a live problem (explained), as related but not
|
|
520
|
+
* what matters (related), never linked a reading to it (unaddressed), or
|
|
521
|
+
* called it a cause or symptom while still reporting healthy (contradiction).
|
|
522
|
+
* The reader sees which of the four it is, in words, next to the verdict.
|
|
523
|
+
*/
|
|
524
|
+
export interface InvestigationHealthSignal {
|
|
525
|
+
groupId?: string;
|
|
526
|
+
sourceId?: string;
|
|
527
|
+
title: string;
|
|
528
|
+
status: "explained" | "related" | "unaddressed" | "contradiction";
|
|
529
|
+
role?: string;
|
|
530
|
+
claim?: string;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function investigationHealthSignals(
|
|
534
|
+
projection: { groups: readonly HealthConflictGroup[] },
|
|
535
|
+
caseItems:
|
|
536
|
+
| readonly {
|
|
537
|
+
role: string;
|
|
538
|
+
placement: "card" | "revision" | "source";
|
|
539
|
+
claim: string;
|
|
540
|
+
groupId?: string;
|
|
541
|
+
source?: { turnIndex: number };
|
|
542
|
+
}[]
|
|
543
|
+
| undefined,
|
|
544
|
+
): InvestigationHealthSignal[] {
|
|
545
|
+
const conflicting = investigationHealthConflictGroups(projection);
|
|
546
|
+
if (conflicting.length === 0) return [];
|
|
547
|
+
const twinsOf = healthTwinIds(projection.groups);
|
|
548
|
+
return conflicting.map((group) => {
|
|
549
|
+
const sameStream = twinsOf(group);
|
|
550
|
+
const fresh = (caseItems ?? []).filter(
|
|
551
|
+
(item) =>
|
|
552
|
+
item.groupId &&
|
|
553
|
+
sameStream.has(item.groupId) &&
|
|
554
|
+
item.placement === "card" &&
|
|
555
|
+
!(
|
|
556
|
+
group.latest.source !== undefined &&
|
|
557
|
+
item.source !== undefined &&
|
|
558
|
+
group.latest.source.turnIndex > item.source.turnIndex
|
|
559
|
+
),
|
|
560
|
+
);
|
|
561
|
+
const base = {
|
|
562
|
+
groupId: group.id,
|
|
563
|
+
sourceId: group.latest.source?.id,
|
|
564
|
+
title: group.latest.title ?? group.kind,
|
|
565
|
+
};
|
|
566
|
+
const asserting = fresh.find(
|
|
567
|
+
(item) => item.role === "cause" || item.role === "symptom",
|
|
568
|
+
);
|
|
569
|
+
if (asserting)
|
|
570
|
+
return { ...base, status: "contradiction", role: asserting.role };
|
|
571
|
+
const benign = fresh.find(
|
|
572
|
+
(item) => item.role === "benign" && item.claim.trim() !== "",
|
|
573
|
+
);
|
|
574
|
+
if (benign) return { ...base, status: "explained", claim: benign.claim };
|
|
575
|
+
const demoted = fresh.find(
|
|
576
|
+
(item) => item.role === "demoted" && item.claim.trim() !== "",
|
|
577
|
+
);
|
|
578
|
+
if (demoted) return { ...base, status: "related", claim: demoted.claim };
|
|
579
|
+
return { ...base, status: "unaddressed" };
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
361
583
|
export function investigationHealthConflictGroups<
|
|
362
584
|
G extends HealthConflictGroup,
|
|
363
585
|
>(projection: { groups: readonly G[] }): G[] {
|
|
@@ -410,19 +632,10 @@ export function investigationHealthConflictExplainedBy(
|
|
|
410
632
|
// agent addressed the stream: it explained one card, and the conflict is
|
|
411
633
|
// recorded on its twin. Group ids that share an identity are the same
|
|
412
634
|
// underlying observation for this question.
|
|
413
|
-
const
|
|
414
|
-
for (const group of projection.groups) {
|
|
415
|
-
if (!group.id || !group.identity) continue;
|
|
416
|
-
const key = `${group.kind}\u0000${group.identity}`;
|
|
417
|
-
const ids = twins.get(key) ?? new Set<string>();
|
|
418
|
-
ids.add(group.id);
|
|
419
|
-
twins.set(key, ids);
|
|
420
|
-
}
|
|
635
|
+
const twinsOf = healthTwinIds(projection.groups);
|
|
421
636
|
const titles: string[] = [];
|
|
422
637
|
for (const group of conflicting) {
|
|
423
|
-
const sameStream =
|
|
424
|
-
(group.identity && twins.get(`${group.kind}\u0000${group.identity}`)) ||
|
|
425
|
-
new Set<string>([group.id ?? ""]);
|
|
638
|
+
const sameStream = twinsOf(group);
|
|
426
639
|
const onGroup = caseItems.filter(
|
|
427
640
|
(item) => item.groupId && sameStream.has(item.groupId),
|
|
428
641
|
);
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
splitStory,
|
|
4
|
+
storyHasPlacements,
|
|
5
|
+
storyPlainText,
|
|
6
|
+
storyReferenceHref,
|
|
7
|
+
storyReferenceIndex,
|
|
8
|
+
UNRESOLVED_STORY_INDEX,
|
|
9
|
+
} from "./investigationStory";
|
|
10
|
+
|
|
11
|
+
describe("splitStory", () => {
|
|
12
|
+
it("places a marker on its own line and references one inside a sentence", () => {
|
|
13
|
+
const { segments } = splitStory(
|
|
14
|
+
"The pod crashes.\n\n[[radar:evidence=0]]\n\nAs [[radar:evidence=0]] shows, auth fails.\n [[radar:evidence=2]] \nDone.",
|
|
15
|
+
);
|
|
16
|
+
expect(segments).toEqual([
|
|
17
|
+
{ kind: "prose", markdown: "The pod crashes." },
|
|
18
|
+
{ kind: "placement", index: 0 },
|
|
19
|
+
{
|
|
20
|
+
kind: "prose",
|
|
21
|
+
markdown:
|
|
22
|
+
"As [[[radar:evidence=0]]](#radar-evidence-0) shows, auth fails.",
|
|
23
|
+
},
|
|
24
|
+
{ kind: "placement", index: 2 },
|
|
25
|
+
{ kind: "prose", markdown: "Done." },
|
|
26
|
+
]);
|
|
27
|
+
expect(JSON.stringify(segments)).toContain("#radar-evidence-0)");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("treats markers inside fences, inline code and blockquotes as literal text", () => {
|
|
31
|
+
const report = [
|
|
32
|
+
"Quoted output:",
|
|
33
|
+
"```",
|
|
34
|
+
"[[radar:evidence=1]]",
|
|
35
|
+
"```",
|
|
36
|
+
"Inline `[[radar:evidence=1]]` is code, but [[radar:evidence=3]] is not.",
|
|
37
|
+
"> [[radar:evidence=4]]",
|
|
38
|
+
"~~~txt",
|
|
39
|
+
"[[radar:evidence=5]]",
|
|
40
|
+
"~~~",
|
|
41
|
+
].join("\n");
|
|
42
|
+
const { segments } = splitStory(report);
|
|
43
|
+
expect(segments.filter((s) => s.kind === "placement")).toEqual([]);
|
|
44
|
+
expect(JSON.stringify(segments)).toContain("#radar-evidence-3)");
|
|
45
|
+
const prose = segments
|
|
46
|
+
.map((s) => (s.kind === "prose" ? s.markdown : ""))
|
|
47
|
+
.join("\n");
|
|
48
|
+
expect(prose).toContain("`[[radar:evidence=1]]`");
|
|
49
|
+
expect(prose).toContain("> [[radar:evidence=4]]");
|
|
50
|
+
expect(prose).toContain("[[[radar:evidence=3]]](#radar-evidence-3)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("keeps indented-code markers and unbalanced closing fences literal", () => {
|
|
54
|
+
const indented = splitStory("Text.\n\n [[radar:evidence=0]]");
|
|
55
|
+
expect(indented.segments.every((s) => s.kind === "prose")).toBe(true);
|
|
56
|
+
expect(JSON.stringify(indented.segments)).not.toContain("#radar-evidence");
|
|
57
|
+
expect(
|
|
58
|
+
indented.segments.map((s) => s.kind === "prose" && s.markdown).join("\n"),
|
|
59
|
+
).toContain(" [[radar:evidence=0]]");
|
|
60
|
+
const fence = splitStory(
|
|
61
|
+
"```\n````not-a-close\n[[radar:evidence=0]]\n```\n\n[[radar:evidence=1]]",
|
|
62
|
+
);
|
|
63
|
+
expect(
|
|
64
|
+
fence.segments
|
|
65
|
+
.filter((s) => s.kind === "placement")
|
|
66
|
+
.map((s) => s.kind === "placement" && s.index),
|
|
67
|
+
).toEqual([1]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("resolves a ref-form marker to its item, and flags one nothing cites", () => {
|
|
71
|
+
const ref = "ev_" + "a".repeat(26) + "_" + "b".repeat(26);
|
|
72
|
+
const stray = "ev_" + "c".repeat(26) + "_" + "d".repeat(26);
|
|
73
|
+
const resolve = (candidate: string) => (candidate === ref ? 2 : undefined);
|
|
74
|
+
const { segments } = splitStory(
|
|
75
|
+
`The log shows it:\n\n[[radar:evidence-ref=${ref}]]\n\nSee [[radar:evidence-ref=${ref}|compact]] and [[radar:evidence-ref=${stray}]].`,
|
|
76
|
+
resolve,
|
|
77
|
+
);
|
|
78
|
+
expect(segments[1]).toEqual({ kind: "placement", index: 2 });
|
|
79
|
+
expect(JSON.stringify(segments)).toContain("#radar-evidence-2)");
|
|
80
|
+
expect(JSON.stringify(segments)).toContain("#radar-evidence--1)");
|
|
81
|
+
expect(segments[2]).toMatchObject({ kind: "prose" });
|
|
82
|
+
const inline = (segments[2] as { markdown: string }).markdown;
|
|
83
|
+
expect(inline).toContain("](#radar-evidence-2)");
|
|
84
|
+
expect(inline).toContain(`](#radar-evidence-${UNRESOLVED_STORY_INDEX})`);
|
|
85
|
+
expect(storyPlainText(`x [[radar:evidence-ref=${ref}]] y`)).toBe("x y");
|
|
86
|
+
});
|
|
87
|
+
it("keeps a marker inside a double-backtick code span literal", () => {
|
|
88
|
+
const { segments } = splitStory(
|
|
89
|
+
"Write `` [[radar:evidence=0]] `` to place, then see [[radar:evidence=0]].",
|
|
90
|
+
);
|
|
91
|
+
expect(JSON.stringify(segments)).toContain("#radar-evidence-0)");
|
|
92
|
+
expect((segments[0] as { markdown: string }).markdown).toContain(
|
|
93
|
+
"`` [[radar:evidence=0]] ``",
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps a marker literal inside a code span that runs across lines", () => {
|
|
98
|
+
const split = splitStory(
|
|
99
|
+
"Quoted `example\n[[radar:evidence=0]]\nend` here.\n\n[[radar:evidence=0]]",
|
|
100
|
+
);
|
|
101
|
+
expect(split.segments.map((segment) => segment.kind)).toEqual([
|
|
102
|
+
"prose",
|
|
103
|
+
"placement",
|
|
104
|
+
]);
|
|
105
|
+
expect(JSON.stringify(split.segments)).not.toContain("#radar-evidence");
|
|
106
|
+
expect((split.segments[0] as { markdown: string }).markdown).toContain(
|
|
107
|
+
"[[radar:evidence=0]]\nend`",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("closes an open span at a fence, at a whitespace-only line, and keeps tab-indented lines literal", () => {
|
|
112
|
+
const kinds = (report: string) =>
|
|
113
|
+
splitStory(report).segments.map((segment) => segment.kind);
|
|
114
|
+
expect(kinds("Quoted `example\n~~~\nx\n~~~\n[[radar:evidence=0]]")).toEqual(
|
|
115
|
+
["prose", "placement"],
|
|
116
|
+
);
|
|
117
|
+
expect(kinds("Quoted `example\n \n[[radar:evidence=0]]")).toEqual([
|
|
118
|
+
"prose",
|
|
119
|
+
"placement",
|
|
120
|
+
]);
|
|
121
|
+
// A tab-indented fence is indented code, so the marker between two of
|
|
122
|
+
// them stands on its own line in prose.
|
|
123
|
+
expect(kinds("\t~~~\n[[radar:evidence=0]]\n\t~~~")).toEqual([
|
|
124
|
+
"prose",
|
|
125
|
+
"placement",
|
|
126
|
+
"prose",
|
|
127
|
+
]);
|
|
128
|
+
expect(kinds("\t[[radar:evidence=0]]")).toEqual(["prose"]);
|
|
129
|
+
expect(
|
|
130
|
+
JSON.stringify(splitStory("\t[[radar:evidence=0]]").segments),
|
|
131
|
+
).not.toContain("#radar-evidence");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("reads the compact variant as a placement flag", () => {
|
|
135
|
+
const { segments } = splitStory(
|
|
136
|
+
"A.\n[[radar:evidence=2|compact]]\nB [[radar:evidence=2|compact]].",
|
|
137
|
+
);
|
|
138
|
+
expect(segments[1]).toEqual({ kind: "placement", index: 2, compact: true });
|
|
139
|
+
expect(segments[2]).toEqual({
|
|
140
|
+
kind: "prose",
|
|
141
|
+
markdown: "B [[[radar:evidence=2|compact]]](#radar-evidence-2).",
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("keeps an unterminated fence literal to the end", () => {
|
|
146
|
+
const { segments } = splitStory("```\n[[radar:evidence=0]]\nstill code");
|
|
147
|
+
expect(segments).toEqual([
|
|
148
|
+
{ kind: "prose", markdown: "```\n[[radar:evidence=0]]\nstill code" },
|
|
149
|
+
]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("round-trips reference hrefs and strips markers for plain text", () => {
|
|
153
|
+
expect(storyReferenceIndex(storyReferenceHref(7))).toBe(7);
|
|
154
|
+
expect(storyReferenceIndex("#other")).toBeUndefined();
|
|
155
|
+
expect(
|
|
156
|
+
storyPlainText("A.\n\n[[radar:evidence=0]]\n\nB [[radar:evidence=1]]."),
|
|
157
|
+
).toBe("A.\n\nB .");
|
|
158
|
+
expect(storyHasPlacements("no markers")).toBe(false);
|
|
159
|
+
expect(storyHasPlacements("x [[radar:evidence=0]]")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|