@shortlink-org/portolan 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -6
- package/catalog/enum_test.go +46 -0
- package/catalog/evidence_test.go +35 -0
- package/catalog/model.go +1213 -0
- package/catalog/roundtrip_test.go +210 -0
- package/catalog/via_test.go +38 -0
- package/cli/init.test.mjs +6 -1
- package/cli/portolan.mjs +14 -1
- package/cli/portolan.test.mjs +49 -0
- package/go.mod +21 -0
- package/go.sum +34 -0
- package/internal/gocall/README.md +19 -0
- package/internal/gocall/analyze.go +189 -0
- package/internal/gocall/analyze_test.go +107 -0
- package/internal/gohttp/analyze.go +2562 -0
- package/internal/gohttp/destination.go +373 -0
- package/internal/gohttp/endpoints.go +1067 -0
- package/internal/gohttp/roots.go +320 -0
- package/internal/gohttp/typed.go +96 -0
- package/internal/goscan/constants.go +85 -0
- package/internal/goscan/goscan_test.go +227 -0
- package/internal/goscan/index.go +629 -0
- package/internal/goscan/index_test.go +66 -0
- package/internal/goscan/names.go +52 -0
- package/internal/goscan/parse_test.go +11 -0
- package/internal/goscan/source.go +37 -0
- package/internal/goscan/tree.go +284 -0
- package/internal/goscan/types.go +99 -0
- package/internal/wsdl/ids.go +127 -0
- package/internal/wsdl/ids_test.go +21 -0
- package/internal/wsdl/model.go +70 -0
- package/internal/wsdl/parse.go +949 -0
- package/internal/wsdl/parse_test.go +170 -0
- package/package.json +28 -15
- package/plugin/describe.go +156 -0
- package/plugin/describe_test.go +114 -0
- package/plugin/protocol.go +141 -0
- package/plugin/schematest/schematest.go +126 -0
- package/plugins/README.md +404 -50
- package/plugins/cmd/portolan-http-clients/main.go +19 -0
- package/plugins/extract-argocd/options.schema.json +44 -0
- package/plugins/extract-celery/extract.py +0 -2
- package/plugins/extract-celery/extract_test.py +1 -1
- package/plugins/extract-celery/main.py +1 -0
- package/plugins/extract-csharp-ddd/README.md +213 -0
- package/plugins/extract-csharp-ddd/options.schema.json +55 -0
- package/plugins/extract-django/README.md +52 -19
- package/plugins/extract-django/choices.py +47 -0
- package/plugins/extract-django/domain.py +30 -18
- package/plugins/extract-django/extract.py +21 -7
- package/plugins/extract-django/extract_test.py +68 -2
- package/plugins/extract-django/lifecycle.py +4 -28
- package/plugins/extract-django/main.py +1 -0
- package/plugins/extract-django/operations.py +1 -1
- package/plugins/extract-django/routing_test.py +109 -1
- package/plugins/extract-django/rules.py +154 -0
- package/plugins/extract-django/rules_test.py +158 -0
- package/plugins/extract-django/store.py +1 -1
- package/plugins/extract-django/transport.py +101 -55
- package/plugins/extract-django/verbs.py +241 -0
- package/plugins/extract-go/README.md +47 -0
- package/plugins/extract-go-sqs/options.schema.json +21 -0
- package/plugins/extract-http-clients/describe.go +20 -0
- package/plugins/extract-http-clients/describe_test.go +11 -0
- package/plugins/extract-http-clients/extract.go +740 -0
- package/plugins/extract-http-clients/extract_test.go +1561 -0
- package/plugins/extract-http-clients/main.go +41 -0
- package/plugins/extract-java/build/org/portolan/extract/Extract.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Main.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Builder.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Input.class +0 -0
- package/plugins/extract-java/build/org/portolan/extract/Protocol$Options.class +0 -0
- package/plugins/extract-k8s/options.schema.json +32 -0
- package/plugins/extract-laravel/Cargo.lock +962 -0
- package/plugins/extract-laravel/Cargo.toml +20 -0
- package/plugins/extract-laravel/README.md +200 -0
- package/plugins/extract-laravel/options.schema.json +68 -0
- package/plugins/extract-laravel/rustfmt.toml +1 -0
- package/plugins/extract-php-ddd/Cargo.lock +972 -0
- package/plugins/extract-php-ddd/Cargo.toml +22 -0
- package/plugins/extract-php-ddd/README.md +141 -0
- package/plugins/extract-php-ddd/options.schema.json +50 -0
- package/plugins/extract-php-ddd/rustfmt.toml +1 -0
- package/plugins/extract-python-kafka/extract.py +0 -2
- package/plugins/extract-python-kafka/extract_test.py +1 -1
- package/plugins/extract-python-kafka/main.py +1 -0
- package/plugins/extract-terraform/options.schema.json +26 -0
- package/plugins/extract-ts/extract.test.ts +2 -2
- package/plugins/extract-ts/extract.ts +4 -5
- package/plugins/extract-ts/graphql.test.ts +1 -1
- package/plugins/extract-ts/main.ts +1 -0
- package/plugins/openapi/ids.go +261 -0
- package/plugins/openapi/ids_test.go +98 -0
- package/plugins/phpscan/Cargo.lock +953 -0
- package/plugins/phpscan/Cargo.toml +19 -0
- package/plugins/phpscan/rustfmt.toml +1 -0
- package/plugins/portolan-go.wasm +0 -0
- package/plugins/pyplugin/catalog.py +24 -1
- package/plugins/pyplugin/protocol.py +1 -5
- package/plugins/verify-otel/options.schema.json +12 -0
- package/portolan.json +132 -4
- package/rules/builtin.json +218 -0
- package/schema/portolan.schema.json +905 -4
- package/scripts/README.md +21 -13
- package/scripts/catalog-sources.mjs +6 -0
- package/scripts/delivery-presets.mjs +57 -14
- package/scripts/diff.mjs +5 -1
- package/scripts/django-aggregates.test.mjs +58 -0
- package/scripts/gen-likec4.mjs +150 -17
- package/scripts/gen-likec4.test.mjs +96 -0
- package/scripts/gen.mjs +148 -118
- package/scripts/gitops-example.test.mjs +108 -0
- package/scripts/go-discovery.test.mjs +30 -0
- package/scripts/history.mjs +186 -3
- package/scripts/history.test.mjs +1 -1
- package/scripts/host-plugins/fetch-argocd.mjs +338 -0
- package/scripts/host-plugins/fetch-argocd.options.json +49 -0
- package/scripts/host-plugins/fetch-argocd.test.mjs +274 -0
- package/scripts/host-plugins/fetch-bsr.mjs +1 -0
- package/scripts/host-plugins/fetch-csr.mjs +1 -0
- package/scripts/host-plugins/fetch-git.mjs +78 -21
- package/scripts/host-plugins/fetch-git.test.mjs +62 -8
- package/scripts/host-plugins/fetch-k8s.mjs +263 -0
- package/scripts/host-plugins/fetch-k8s.options.json +50 -0
- package/scripts/host-plugins/fetch-k8s.test.mjs +259 -0
- package/scripts/host-plugins/k8s-topology.mjs +183 -0
- package/scripts/json-format.mjs +192 -0
- package/scripts/json-format.test.mjs +97 -0
- package/scripts/local-api.mjs +287 -12
- package/scripts/local-api.test.mjs +150 -5
- package/scripts/local-discovery.mjs +94 -9
- package/scripts/manifest.mjs +14 -3
- package/scripts/manifest.test.mjs +24 -0
- package/scripts/output-diff.mjs +94 -0
- package/scripts/output-diff.test.mjs +36 -0
- package/scripts/package-smoke.mjs +62 -4
- package/scripts/plugin-host.mjs +45 -3
- package/scripts/plugin-host.test.mjs +9 -0
- package/scripts/plugin-wasm-worker.mjs +4 -1
- package/scripts/plugins-fresh.mjs +97 -0
- package/scripts/plugins-fresh.test.mjs +64 -0
- package/scripts/provenance.mjs +72 -0
- package/scripts/provenance.test.mjs +149 -0
- package/scripts/run-builtin.mjs +62 -7
- package/scripts/schema.mjs +167 -19
- package/scripts/trace-trials.mjs +176 -0
- package/scripts/trace-trials.test.mjs +142 -0
- package/scripts/warning-policy.mjs +167 -0
- package/scripts/warning-policy.test.mjs +93 -0
- package/src/app/Breadcrumbs.test.ts +3 -0
- package/src/app/Breadcrumbs.tsx +3 -0
- package/src/app/CatalogApp.tsx +2 -0
- package/src/app/Sidebar.tsx +3 -3
- package/src/app/SidebarFooter.tsx +20 -4
- package/src/catalog-docs.test.ts +64 -0
- package/src/catalog-docs.ts +35 -0
- package/src/catalog-error.test.ts +15 -0
- package/src/catalog-index.ts +25 -0
- package/src/catalog-model.ts +293 -5
- package/src/catalog-validation.ts +113 -2
- package/src/catalog.test.ts +40 -1
- package/src/chat/Starter.tsx +5 -11
- package/src/chat/tools.test.ts +27 -0
- package/src/chat/tools.ts +5 -9
- package/src/components/CatalogStamp.tsx +10 -8
- package/src/components/ChannelRows.test.tsx +76 -1
- package/src/components/ChannelRows.tsx +223 -60
- package/src/components/DeploymentRows.tsx +131 -0
- package/src/components/DocsLinks.test.tsx +27 -0
- package/src/components/DocsLinks.tsx +56 -0
- package/src/components/FieldTree.tsx +5 -2
- package/src/components/HTTPDestinationEvidence.test.tsx +23 -0
- package/src/components/HTTPDestinationEvidence.tsx +31 -0
- package/src/components/Integrations.tsx +1 -1
- package/src/components/MachineDocs.tsx +6 -5
- package/src/components/MethodRows.tsx +9 -2
- package/src/components/PluginIcon.tsx +77 -0
- package/src/components/ProblemRow.tsx +173 -153
- package/src/components/RelationEvidence.test.tsx +14 -0
- package/src/components/RelationEvidence.tsx +53 -0
- package/src/components/RuleMarks.tsx +22 -0
- package/src/components/ShapeRows.tsx +24 -20
- package/src/data.ts +46 -7
- package/src/enrich.test.ts +459 -4
- package/src/enrich.ts +308 -7
- package/src/er/ErCanvas.tsx +218 -12
- package/src/er/GroupNode.tsx +57 -0
- package/src/er/StoreHeader.tsx +1 -0
- package/src/er/layout.test.ts +85 -2
- package/src/er/layout.ts +140 -5
- package/src/er/spec.test.ts +17 -0
- package/src/er/spec.ts +23 -10
- package/src/flow/Recordings.test.tsx +52 -0
- package/src/flow/Recordings.tsx +236 -0
- package/src/flow/StepDetail.tsx +59 -0
- package/src/flow/TraceTrial.tsx +419 -0
- package/src/flow/evidence.test.ts +16 -0
- package/src/flow/evidence.ts +34 -0
- package/src/flow/examples.test.ts +33 -0
- package/src/flow/examples.ts +37 -0
- package/src/flow/outline.test.ts +28 -0
- package/src/flow/outline.ts +7 -2
- package/src/flow/trace-trial-resume.test.ts +42 -0
- package/src/flow/trace-trial-resume.ts +74 -0
- package/src/graph/elk.ts +78 -0
- package/src/index.css +68 -0
- package/src/landing/DraggableReveal.tsx +3 -2
- package/src/landing/EvidencePipeline.tsx +105 -0
- package/src/landing/LandingPage.tsx +17 -70
- package/src/landing/ProductTour.tsx +6 -6
- package/src/lib/all-problems.ts +27 -17
- package/src/lib/catalog-diff.ts +1 -1
- package/src/lib/centrality.test.ts +251 -0
- package/src/lib/centrality.ts +232 -0
- package/src/lib/confluence.test.ts +37 -0
- package/src/lib/confluence.ts +41 -0
- package/src/lib/context-color.ts +1 -1
- package/src/lib/deployment-drift.ts +22 -0
- package/src/lib/derive.ts +43 -69
- package/src/lib/django-aggregates.d.mts +9 -0
- package/src/lib/django-aggregates.mjs +36 -0
- package/src/lib/django-aggregates.test.ts +29 -0
- package/src/lib/django-aggregates.ts +5 -0
- package/src/lib/environments.test.ts +83 -0
- package/src/lib/environments.ts +57 -0
- package/src/lib/integration-url.test.ts +30 -0
- package/src/lib/integration-url.ts +63 -0
- package/src/lib/kafka-ui.ts +3 -45
- package/src/lib/local-api.ts +116 -4
- package/src/lib/notion.ts +13 -0
- package/src/lib/plugin-index.json +2765 -0
- package/src/lib/plugins.test.ts +68 -0
- package/src/lib/plugins.ts +264 -0
- package/src/lib/problem-flows.test.ts +61 -0
- package/src/lib/problem-flows.ts +78 -0
- package/src/lib/problem-rules-cel.d.mts +43 -0
- package/src/lib/problem-rules-cel.mjs +407 -0
- package/src/lib/problem-rules.test.ts +287 -0
- package/src/lib/problem-rules.ts +271 -0
- package/src/lib/problem-subjects.ts +737 -0
- package/src/lib/rule-entries.ts +39 -0
- package/src/lib/{data-problems.test.ts → rules-data.test.ts} +18 -16
- package/src/lib/rules-deploy.test.ts +133 -0
- package/src/lib/{problems.test.ts → rules-edges.test.ts} +8 -3
- package/src/lib/{proto-problems.test.ts → rules-proto.test.ts} +9 -25
- package/src/lib/{wire-problems.test.ts → rules-wire.test.ts} +13 -11
- package/src/lib/rules.test.ts +51 -0
- package/src/lib/rules.ts +86 -0
- package/src/lib/setup-info.test.ts +17 -0
- package/src/lib/setup-info.ts +58 -0
- package/src/lib/shape.test.ts +32 -0
- package/src/lib/shape.ts +30 -6
- package/src/lib/tech.ts +16 -0
- package/src/lib/trace-project.test.ts +34 -0
- package/src/lib/trace-project.ts +50 -0
- package/src/lib/use-problems.ts +23 -0
- package/src/lib/warnings.test.ts +63 -0
- package/src/lib/warnings.ts +260 -0
- package/src/likec4/ids.test.ts +6 -2
- package/src/likec4/ids.ts +43 -0
- package/src/main.tsx +23 -0
- package/src/map/ContextMapGraph.tsx +76 -32
- package/src/merge-deployments.test.ts +127 -0
- package/src/merge.test.ts +82 -0
- package/src/merge.ts +189 -18
- package/src/pages/AggregatePage.tsx +65 -14
- package/src/pages/ContextMap.tsx +45 -3
- package/src/pages/ContextPage.tsx +8 -5
- package/src/pages/EventPage.tsx +15 -5
- package/src/pages/FlowDetail.tsx +23 -2
- package/src/pages/GraphPage.tsx +40 -3
- package/src/pages/Overview.tsx +152 -12
- package/src/pages/PluginIndex.tsx +190 -0
- package/src/pages/Problems.tsx +396 -128
- package/src/pages/ServicePage.tsx +62 -5
- package/src/pages/Settings.tsx +217 -43
- package/src/pages/settings/AboutSettings.tsx +8 -1
- package/src/pages/settings/DjangoAggregateChoices.tsx +79 -0
- package/src/pages/settings/IntegrationsSettings.tsx +63 -17
- package/src/pages/settings/RecordingSettings.tsx +138 -0
- package/src/pages/settings/RulesSettings.tsx +825 -0
- package/src/routes.test.ts +9 -0
- package/src/routes.ts +22 -1
- package/src/selection/DetailPanel.tsx +15 -0
- package/src/virtual-provenance.d.ts +11 -0
- package/vite.config.ts +5 -0
- package/scripts/vendor-lock.mjs +0 -58
- package/scripts/vendor-lock.test.mjs +0 -69
- package/src/lib/data-problems.ts +0 -314
- package/src/lib/proto-problems.ts +0 -237
- package/src/lib/wire-problems.ts +0 -342
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// A recording, uploaded, on its way into the catalog.
|
|
2
|
+
//
|
|
3
|
+
// The generator has run over a copy of the workspace with the recording in
|
|
4
|
+
// place, and this is what it said: the flows the recording showed, what it
|
|
5
|
+
// raised on them and what it added, and the names it could not place. The
|
|
6
|
+
// reader maps those names, keeps the recording or does not, and watches the
|
|
7
|
+
// real run when they keep it. The panel is the same on the flow page and in
|
|
8
|
+
// settings, because the questions are.
|
|
9
|
+
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { ChevronDown, CircleAlert, LoaderCircle, Play, X } from "lucide-react";
|
|
12
|
+
import { Link } from "react-router";
|
|
13
|
+
import { catalog } from "../data";
|
|
14
|
+
import { useToastStore } from "../app/toast";
|
|
15
|
+
import { plural } from "../lib/format";
|
|
16
|
+
import {
|
|
17
|
+
applyTraceTrial,
|
|
18
|
+
cancelGeneration,
|
|
19
|
+
disposeTraceTrial,
|
|
20
|
+
subscribeToRun,
|
|
21
|
+
} from "../lib/local-api";
|
|
22
|
+
import type { RunEvent, TraceTrial } from "../lib/local-api";
|
|
23
|
+
import { paths } from "../routes";
|
|
24
|
+
|
|
25
|
+
type Mapping = Record<string, string>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The events of a run, as they arrive. The server keeps every run's events
|
|
29
|
+
* and replays them to a late subscriber, so a run picked back up after a
|
|
30
|
+
* reload reads the same as one watched from the start. `ended` says the
|
|
31
|
+
* stream closed: with a `process-finished` in it the run is over, without
|
|
32
|
+
* one the server no longer knows the run.
|
|
33
|
+
*/
|
|
34
|
+
function useRunEvents(runId: string | null): { events: RunEvent[]; ended: boolean } {
|
|
35
|
+
const [events, setEvents] = useState<RunEvent[]>([]);
|
|
36
|
+
const [ended, setEnded] = useState(false);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setEvents([]);
|
|
39
|
+
setEnded(false);
|
|
40
|
+
if (!runId) return;
|
|
41
|
+
return subscribeToRun(
|
|
42
|
+
runId,
|
|
43
|
+
(event) => setEvents((current) => [...current, event]),
|
|
44
|
+
() => setEnded(true),
|
|
45
|
+
);
|
|
46
|
+
}, [runId]);
|
|
47
|
+
return { events, ended };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Lost({ onClose }: { onClose: () => void }) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="flex flex-col gap-3">
|
|
53
|
+
<div className="flex items-start gap-2 text-ink">
|
|
54
|
+
<CircleAlert size={16} className="mt-0.5 shrink-0 text-muted" />
|
|
55
|
+
<div>The server no longer knows this run - it was restarted, or the run was cancelled. Nothing more can be said about it here.</div>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="flex justify-end">
|
|
58
|
+
<button type="button" className="tbtn" onClick={onClose}>Close</button>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Progress({ events, label }: { events: RunEvent[]; label: string }) {
|
|
65
|
+
const pipeline = events.find((e) => e.type === "pipeline-ready");
|
|
66
|
+
const steps = events.filter((e) => e.type === "step-finished");
|
|
67
|
+
const active = events
|
|
68
|
+
.slice()
|
|
69
|
+
.reverse()
|
|
70
|
+
.find((e): e is Extract<RunEvent, { type: "step-started" }> => e.type === "step-started");
|
|
71
|
+
const total = pipeline?.type === "pipeline-ready" ? pipeline.stepCount : 0;
|
|
72
|
+
const percent = total ? Math.round((steps.length / total) * 100) : 0;
|
|
73
|
+
return (
|
|
74
|
+
<div>
|
|
75
|
+
<div className="mono flex items-center gap-2 text-muted">
|
|
76
|
+
<LoaderCircle size={14} className="animate-spin text-accent" />
|
|
77
|
+
{active ? `${active.phase} · ${active.plugin}` : label}
|
|
78
|
+
<span className="ml-auto">{steps.length} / {total || "?"}</span>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-surface">
|
|
81
|
+
<div className="h-full bg-accent transition-[width]" style={{ width: `${percent}%` }} />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function Logs({ events }: { events: RunEvent[] }) {
|
|
88
|
+
const logs = events.filter((e) => e.type === "log");
|
|
89
|
+
if (!logs.length) return null;
|
|
90
|
+
return (
|
|
91
|
+
<details className="mt-3">
|
|
92
|
+
<summary className="cursor-pointer text-muted">Generator log · {logs.length} lines</summary>
|
|
93
|
+
<pre className="mono mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-control bg-surface p-3 text-muted">
|
|
94
|
+
{logs.map((e) => (e.type === "log" ? e.message : "")).join("\n")}
|
|
95
|
+
</pre>
|
|
96
|
+
</details>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A name the verifier could not place, and the catalog id it should be.
|
|
102
|
+
* The list of ids is offered rather than enforced: the verifier says when
|
|
103
|
+
* the id names nothing, on the next run.
|
|
104
|
+
*/
|
|
105
|
+
type MappingKind = "service" | "event" | "route";
|
|
106
|
+
|
|
107
|
+
const PLACEHOLDER: Record<MappingKind, string> = {
|
|
108
|
+
service: "context.service",
|
|
109
|
+
event: "context.service.aggregate.Event",
|
|
110
|
+
route: "operation name, as the interface declares it",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function MappingRow({
|
|
114
|
+
kind,
|
|
115
|
+
name,
|
|
116
|
+
value,
|
|
117
|
+
onChange,
|
|
118
|
+
}: {
|
|
119
|
+
kind: MappingKind;
|
|
120
|
+
name: string;
|
|
121
|
+
value: string;
|
|
122
|
+
onChange: (next: string) => void;
|
|
123
|
+
}) {
|
|
124
|
+
const listId = `trace-${kind}-ids`;
|
|
125
|
+
return (
|
|
126
|
+
<div className="grid items-center gap-2 sm:grid-cols-[1fr_auto_1fr]">
|
|
127
|
+
<span className="mono truncate text-ink" title={name}>{name}</span>
|
|
128
|
+
<span className="mono text-faint">{kind === "route" ? "answers on operation" : `is ${kind}`}</span>
|
|
129
|
+
<input
|
|
130
|
+
className="mono w-full rounded-control border border-line bg-canvas px-2.5 py-1.5 text-ink"
|
|
131
|
+
list={listId}
|
|
132
|
+
value={value}
|
|
133
|
+
placeholder={PLACEHOLDER[kind]}
|
|
134
|
+
onChange={(e) => onChange(e.target.value)}
|
|
135
|
+
aria-label={`Catalog ${kind} for ${name}`}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function IdLists() {
|
|
142
|
+
const services = useMemo(
|
|
143
|
+
() => catalog.contexts.flatMap((c) => c.services.map((s) => s.id)),
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
const events = useMemo(
|
|
147
|
+
() =>
|
|
148
|
+
catalog.contexts.flatMap((c) =>
|
|
149
|
+
c.services.flatMap((s) => s.aggregates.flatMap((a) => a.events.map((e) => e.id))),
|
|
150
|
+
),
|
|
151
|
+
[],
|
|
152
|
+
);
|
|
153
|
+
const operations = useMemo(
|
|
154
|
+
() =>
|
|
155
|
+
[...new Set(
|
|
156
|
+
catalog.contexts.flatMap((c) =>
|
|
157
|
+
c.services.flatMap((s) => s.provides.flatMap((p) => p.methods.map((m) => m.name))),
|
|
158
|
+
),
|
|
159
|
+
)].sort(),
|
|
160
|
+
[],
|
|
161
|
+
);
|
|
162
|
+
return (
|
|
163
|
+
<>
|
|
164
|
+
<datalist id="trace-service-ids">{services.map((id) => <option key={id} value={id} />)}</datalist>
|
|
165
|
+
<datalist id="trace-event-ids">{events.map((id) => <option key={id} value={id} />)}</datalist>
|
|
166
|
+
<datalist id="trace-route-ids">{operations.map((name) => <option key={name} value={name} />)}</datalist>
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function FlowRow({ flow }: { flow: TraceTrial["flows"][number] }) {
|
|
172
|
+
return (
|
|
173
|
+
<div className="grid gap-x-3 gap-y-1 px-3 py-2 sm:grid-cols-[1fr_auto]">
|
|
174
|
+
<div className="min-w-0">
|
|
175
|
+
<Link to={paths.flow(flow.slug)} className="truncate font-medium text-ink hover:underline" title={flow.id}>
|
|
176
|
+
{flow.name}
|
|
177
|
+
</Link>
|
|
178
|
+
<div className="mono mt-0.5 flex flex-wrap gap-x-2 text-muted">
|
|
179
|
+
<span>{flow.kind === "observed" ? "no flow in the catalog opens this way" : `${flow.verified} of ${flow.steps} steps verified`}</span>
|
|
180
|
+
{flow.added ? <span>+{flow.added} {plural(flow.added, "hop")} the code does not declare</span> : null}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex flex-wrap items-center gap-1 sm:justify-end">
|
|
184
|
+
{flow.inRecording ? (
|
|
185
|
+
<span className="chip status-verified" title="Traces in this recording that showed the flow">
|
|
186
|
+
{flow.traces} {plural(flow.traces, "trace")} · {flow.shown} {plural(flow.shown, "step")} shown
|
|
187
|
+
</span>
|
|
188
|
+
) : (
|
|
189
|
+
<span className="chip" title="Written from recordings the project already had">from earlier recordings</span>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The panel. `runId` is the trial run; when the recording is kept and the
|
|
198
|
+
* reader asked for it, the write run takes its place until it finishes.
|
|
199
|
+
*/
|
|
200
|
+
export function TraceTrialPanel({
|
|
201
|
+
runId,
|
|
202
|
+
initialWriteRunId = null,
|
|
203
|
+
onWriteStarted,
|
|
204
|
+
onDone,
|
|
205
|
+
}: {
|
|
206
|
+
runId: string;
|
|
207
|
+
/** The write run already started by keeping this recording, when the page comes back to it. */
|
|
208
|
+
initialWriteRunId?: string | null;
|
|
209
|
+
/** Keeping the recording started a write run: the page remembers it for a reload. */
|
|
210
|
+
onWriteStarted?: (writeRunId: string) => void;
|
|
211
|
+
/** The trial is over: kept and regenerated, kept, or discarded. */
|
|
212
|
+
onDone: (outcome: "kept" | "discarded") => void;
|
|
213
|
+
}) {
|
|
214
|
+
const say = useToastStore((s) => s.say);
|
|
215
|
+
const { events: trialEvents, ended: trialEnded } = useRunEvents(runId);
|
|
216
|
+
const [writeRunId, setWriteRunId] = useState<string | null>(initialWriteRunId);
|
|
217
|
+
const { events: writeEvents, ended: writeEnded } = useRunEvents(writeRunId);
|
|
218
|
+
const [services, setServices] = useState<Mapping>({});
|
|
219
|
+
const [events, setEvents] = useState<Mapping>({});
|
|
220
|
+
const [routes, setRoutes] = useState<Mapping>({});
|
|
221
|
+
const [busy, setBusy] = useState(false);
|
|
222
|
+
const [kept, setKept] = useState<{ recording: string; manifestChanged: boolean } | null>(null);
|
|
223
|
+
|
|
224
|
+
const trial = trialEvents.find(
|
|
225
|
+
(e): e is Extract<RunEvent, { type: "trace-trial-ready" }> => e.type === "trace-trial-ready",
|
|
226
|
+
);
|
|
227
|
+
const trialFinished = trialEvents.find(
|
|
228
|
+
(e): e is Extract<RunEvent, { type: "process-finished" }> => e.type === "process-finished",
|
|
229
|
+
);
|
|
230
|
+
const writeFinished = writeEvents.find(
|
|
231
|
+
(e): e is Extract<RunEvent, { type: "process-finished" }> => e.type === "process-finished",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const unplaced = useMemo(() => {
|
|
235
|
+
const seen = new Set<string>();
|
|
236
|
+
return (trial?.warnings ?? []).filter((w) => {
|
|
237
|
+
if ((w.kind !== "service" && w.kind !== "event" && w.kind !== "route") || !w.name) return false;
|
|
238
|
+
const key = `${w.kind}:${w.name}`;
|
|
239
|
+
if (seen.has(key)) return false;
|
|
240
|
+
seen.add(key);
|
|
241
|
+
return true;
|
|
242
|
+
});
|
|
243
|
+
}, [trial]);
|
|
244
|
+
const others = (trial?.warnings ?? []).filter((w) => w.kind !== "service" && w.kind !== "event" && w.kind !== "route");
|
|
245
|
+
const shown = (trial?.flows ?? []).filter((f) => f.inRecording);
|
|
246
|
+
const rest = (trial?.flows ?? []).filter((f) => !f.inRecording);
|
|
247
|
+
|
|
248
|
+
async function keep(generate: boolean) {
|
|
249
|
+
setBusy(true);
|
|
250
|
+
try {
|
|
251
|
+
const clean = (m: Mapping) => Object.fromEntries(Object.entries(m).filter(([, v]) => v.trim()).map(([k, v]) => [k, v.trim()]));
|
|
252
|
+
const result = await applyTraceTrial(runId, { generate, services: clean(services), events: clean(events), routes: clean(routes) });
|
|
253
|
+
setKept({ recording: result.recording, manifestChanged: result.manifestChanged });
|
|
254
|
+
if (result.run) {
|
|
255
|
+
onWriteStarted?.(result.run.runId);
|
|
256
|
+
setWriteRunId(result.run.runId);
|
|
257
|
+
} else {
|
|
258
|
+
say(`${result.recording} kept`);
|
|
259
|
+
onDone("kept");
|
|
260
|
+
}
|
|
261
|
+
} catch (cause) {
|
|
262
|
+
say(cause instanceof Error ? cause.message : String(cause));
|
|
263
|
+
} finally {
|
|
264
|
+
setBusy(false);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function discard() {
|
|
269
|
+
setBusy(true);
|
|
270
|
+
try {
|
|
271
|
+
await disposeTraceTrial(runId);
|
|
272
|
+
} catch {
|
|
273
|
+
// Already gone is discarded too.
|
|
274
|
+
} finally {
|
|
275
|
+
setBusy(false);
|
|
276
|
+
onDone("discarded");
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The write run: the recording is on disk, the catalog is being written.
|
|
281
|
+
if (writeRunId) {
|
|
282
|
+
if (writeEnded && !writeFinished) return <Lost onClose={() => onDone("kept")} />;
|
|
283
|
+
return (
|
|
284
|
+
<div className="flex flex-col gap-3">
|
|
285
|
+
{writeFinished ? (
|
|
286
|
+
<div className="flex items-start gap-2">
|
|
287
|
+
{writeFinished.status === "ok" ? null : <CircleAlert size={16} className="mt-0.5 shrink-0 text-muted" />}
|
|
288
|
+
<div>
|
|
289
|
+
<div className="font-medium text-ink">
|
|
290
|
+
{writeFinished.status === "ok" ? "Recording kept and documentation regenerated" : `Regeneration ${writeFinished.status}`}
|
|
291
|
+
</div>
|
|
292
|
+
<div className="mono mt-0.5 text-muted">{kept?.recording ?? trial?.recording}</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
) : (
|
|
296
|
+
<Progress events={writeEvents} label="regenerating with the recording in place…" />
|
|
297
|
+
)}
|
|
298
|
+
<Logs events={writeEvents} />
|
|
299
|
+
<div className="flex justify-end gap-2">
|
|
300
|
+
{writeFinished ? (
|
|
301
|
+
<button type="button" className="product-primary" onClick={() => onDone("kept")}>Done</button>
|
|
302
|
+
) : (
|
|
303
|
+
<button type="button" className="tbtn" onClick={() => void cancelGeneration(writeRunId)}>Cancel</button>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!trialFinished) {
|
|
311
|
+
if (trialEnded) return <Lost onClose={() => onDone("discarded")} />;
|
|
312
|
+
return (
|
|
313
|
+
<div className="flex flex-col gap-3">
|
|
314
|
+
<Progress events={trialEvents} label="reading the recording against the catalog…" />
|
|
315
|
+
<Logs events={trialEvents} />
|
|
316
|
+
<div className="flex justify-end">
|
|
317
|
+
<button type="button" className="tbtn" onClick={() => void cancelGeneration(runId)}>Cancel</button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!trial) {
|
|
324
|
+
return (
|
|
325
|
+
<div className="flex flex-col gap-3">
|
|
326
|
+
<div className="flex items-start gap-2 text-ink">
|
|
327
|
+
<CircleAlert size={16} className="mt-0.5 shrink-0 text-muted" />
|
|
328
|
+
<div>The generator did not finish with the recording in place ({trialFinished.status}). Nothing was written.</div>
|
|
329
|
+
</div>
|
|
330
|
+
<Logs events={trialEvents} />
|
|
331
|
+
<div className="flex justify-end">
|
|
332
|
+
<button type="button" className="tbtn" onClick={() => void discard()} disabled={busy}>Close</button>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<div className="flex flex-col gap-4">
|
|
340
|
+
<IdLists />
|
|
341
|
+
<div>
|
|
342
|
+
<div className="mono text-muted">
|
|
343
|
+
{trial.spans} {plural(trial.spans, "span")} · lands at <span className="text-ink">{trial.recording}</span>
|
|
344
|
+
{trial.stepChange === "added" ? " · adds a verify step to portolan.json" : trial.stepChange === "widened" ? " · points the project's verify step at the recordings directory" : ""}
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<section>
|
|
349
|
+
<div className="label mb-1.5">flows this recording showed · {shown.length}</div>
|
|
350
|
+
{shown.length ? (
|
|
351
|
+
<div className="divide-y divide-line rounded-control border border-line">
|
|
352
|
+
{shown.map((flow) => <FlowRow key={flow.id} flow={flow} />)}
|
|
353
|
+
</div>
|
|
354
|
+
) : (
|
|
355
|
+
<div className="rounded-control border border-line bg-surface px-3 py-3 text-muted">
|
|
356
|
+
No trace in the recording opened a flow of this project. A trace opens a flow on the operation it calls in on or the event that arrives; a service the catalog does not know is reported below.
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
{rest.length ? (
|
|
360
|
+
<details className="mt-2 group">
|
|
361
|
+
<summary className="flex cursor-pointer items-center gap-1 text-muted">
|
|
362
|
+
<ChevronDown size={14} className="transition-transform group-open:rotate-180" />
|
|
363
|
+
{rest.length} {plural(rest.length, "flow")} written from recordings the project already had
|
|
364
|
+
</summary>
|
|
365
|
+
<div className="mt-2 divide-y divide-line rounded-control border border-line">
|
|
366
|
+
{rest.map((flow) => <FlowRow key={flow.id} flow={flow} />)}
|
|
367
|
+
</div>
|
|
368
|
+
</details>
|
|
369
|
+
) : null}
|
|
370
|
+
</section>
|
|
371
|
+
|
|
372
|
+
{unplaced.length ? (
|
|
373
|
+
<section>
|
|
374
|
+
<div className="label mb-1.5">names the verifier could not place · {unplaced.length}</div>
|
|
375
|
+
<p className="mb-2 text-muted">
|
|
376
|
+
Say which catalog entry each one is and the mapping is kept in the verify step for every run after this one. Leave one empty to keep the spans it names out; a route left empty opens a flow of its own, written down as it was seen.
|
|
377
|
+
</p>
|
|
378
|
+
<div className="flex flex-col gap-2">
|
|
379
|
+
{unplaced.map((w) =>
|
|
380
|
+
w.kind === "service" ? (
|
|
381
|
+
<MappingRow key={`s:${w.name}`} kind="service" name={w.name!} value={services[w.name!] ?? trial.mappings.services[w.name!] ?? ""} onChange={(v) => setServices((m) => ({ ...m, [w.name!]: v }))} />
|
|
382
|
+
) : w.kind === "event" ? (
|
|
383
|
+
<MappingRow key={`e:${w.name}`} kind="event" name={w.name!} value={events[w.name!] ?? trial.mappings.events[w.name!] ?? ""} onChange={(v) => setEvents((m) => ({ ...m, [w.name!]: v }))} />
|
|
384
|
+
) : (
|
|
385
|
+
<MappingRow key={`r:${w.name}`} kind="route" name={w.name!} value={routes[w.name!] ?? trial.mappings.routes?.[w.name!] ?? ""} onChange={(v) => setRoutes((m) => ({ ...m, [w.name!]: v }))} />
|
|
386
|
+
),
|
|
387
|
+
)}
|
|
388
|
+
</div>
|
|
389
|
+
</section>
|
|
390
|
+
) : null}
|
|
391
|
+
|
|
392
|
+
{others.length ? (
|
|
393
|
+
<details className="group">
|
|
394
|
+
<summary className="flex cursor-pointer items-center gap-1 text-muted">
|
|
395
|
+
<ChevronDown size={14} className="transition-transform group-open:rotate-180" />
|
|
396
|
+
{others.length} {plural(others.length, "other warning")}
|
|
397
|
+
</summary>
|
|
398
|
+
<ul className="mt-2 flex flex-col gap-1">
|
|
399
|
+
{others.map((w, i) => <li key={i} className="mono text-muted">{w.message}</li>)}
|
|
400
|
+
</ul>
|
|
401
|
+
</details>
|
|
402
|
+
) : null}
|
|
403
|
+
|
|
404
|
+
<Logs events={trialEvents} />
|
|
405
|
+
|
|
406
|
+
<div className="flex flex-wrap justify-end gap-2">
|
|
407
|
+
<button type="button" className="tbtn" onClick={() => void discard()} disabled={busy}>
|
|
408
|
+
<X size={14} /> Discard
|
|
409
|
+
</button>
|
|
410
|
+
<button type="button" className="tbtn" onClick={() => void keep(false)} disabled={busy} title="Write the recording beside the project without regenerating now">
|
|
411
|
+
Keep only
|
|
412
|
+
</button>
|
|
413
|
+
<button type="button" className="product-primary" onClick={() => void keep(true)} disabled={busy}>
|
|
414
|
+
{busy ? <LoaderCircle size={14} className="animate-spin" /> : <Play size={14} />} Keep and regenerate
|
|
415
|
+
</button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { buildIndex, type Catalog, type Step } from "../catalog";
|
|
3
|
+
import { stepRelationEvidence } from "./evidence";
|
|
4
|
+
|
|
5
|
+
const index = buildIndex({ generatedAt: "", commit: "", contexts: [], defs: {}, flows: [], adrs: [] } as Catalog);
|
|
6
|
+
it("gives legacy call, event, RPC and response steps an honest source explanation", () => {
|
|
7
|
+
for (const kind of ["call", "event", "rpc", "response"] as const) {
|
|
8
|
+
const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind, status: "declared", line: "handler.go:8" };
|
|
9
|
+
expect(stepRelationEvidence(index, step)).toEqual([{ kind: "call-site", rule: "source-expression", source: "handler.go:8", symbol: undefined }]);
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
it("keeps unresolved status and deduplicates recorded evidence", () => {
|
|
13
|
+
const evidence = { kind: "call-site" as const, rule: "source-expression", source: "handler.go:8" };
|
|
14
|
+
const step: Step = { type: "step", id: "s1", from: "a", to: "b", kind: "call", status: "unresolved", line: evidence.source, evidence: [evidence, evidence] };
|
|
15
|
+
expect(stepRelationEvidence(index, step).map((item) => item.kind)).toEqual(["call-site", "unresolved"]);
|
|
16
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CatalogIndex, RelationEvidence, Step } from "../catalog";
|
|
2
|
+
import { stepRpcContract } from "./answers";
|
|
3
|
+
|
|
4
|
+
/** Adapt older fragments to the same explanation without inventing a path. */
|
|
5
|
+
export function stepRelationEvidence(index: CatalogIndex, step: Step): RelationEvidence[] {
|
|
6
|
+
const items = [...(step.evidence ?? [])];
|
|
7
|
+
if (step.line && !items.some((item) => item.kind === "call-site" && item.source === step.line)) {
|
|
8
|
+
items.push({ kind: "call-site", rule: "source-expression", source: step.line, symbol: step.label });
|
|
9
|
+
}
|
|
10
|
+
const contract = stepRpcContract(index, step);
|
|
11
|
+
if (contract) items.push({ kind: "contract", rule: "catalog-contract", source: contract.provided.source, symbol: contract.id });
|
|
12
|
+
if (step.kind === "event" && step.ref) {
|
|
13
|
+
const event = index.eventById.get(step.ref);
|
|
14
|
+
const version = event?.versions.at(-1);
|
|
15
|
+
if (version) items.push({ kind: "contract", rule: "event-declaration", source: version.source, symbol: step.ref });
|
|
16
|
+
}
|
|
17
|
+
if (step.storeAccess?.source) items.push({ kind: "binding", rule: "repository-sql-call", source: step.storeAccess.source, symbol: step.storeAccess.method });
|
|
18
|
+
if (step.destination?.resolution) {
|
|
19
|
+
const resolution = step.destination.resolution;
|
|
20
|
+
items.push({ kind: "resolution", rule: resolution.basis, symbol: `${resolution.provider} ${resolution.route}` });
|
|
21
|
+
}
|
|
22
|
+
if (step.continuesAt) items.push({ kind: "resolution", rule: "continuation-entrypoint", symbol: step.continuesAt });
|
|
23
|
+
if (step.kind === "response" && step.replyTo) items.push({ kind: "resolution", rule: "response-to-request", symbol: step.replyTo });
|
|
24
|
+
if (step.status === "unresolved" && !items.some((item) => item.kind === "unresolved")) {
|
|
25
|
+
items.push({ kind: "unresolved", rule: "unresolved-target", symbol: step.ref ?? step.label });
|
|
26
|
+
}
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
return items.filter((item) => {
|
|
29
|
+
const key = JSON.stringify(item);
|
|
30
|
+
if (seen.has(key)) return false;
|
|
31
|
+
seen.add(key);
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { exampleRowsFor, formatMs, stepsShownBy } from "./examples";
|
|
4
|
+
|
|
5
|
+
const example = (traceId: string, ...steps: string[]) => ({
|
|
6
|
+
id: `t.jsonl#${traceId}`,
|
|
7
|
+
recording: "examples/auth/telemetry/t.jsonl",
|
|
8
|
+
traceId,
|
|
9
|
+
durationMs: 3,
|
|
10
|
+
steps: steps.map((step, i) => ({ step, durationMs: i + 1, attributes: { "http.route": "/x" } })),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("what the recordings said about one step", () => {
|
|
14
|
+
it("is every trace that showed it, in the flow's order, with what the span said", () => {
|
|
15
|
+
const flow = { examples: [example("a", "s1", "s2"), example("b", "s2", "s2"), example("c", "s3")] };
|
|
16
|
+
const rows = exampleRowsFor(flow, { id: "s2" });
|
|
17
|
+
expect(rows.map((row) => `${row.example.traceId}:${row.shown.durationMs}`)).toEqual(["a:2", "b:1", "b:2"]);
|
|
18
|
+
expect(exampleRowsFor(flow, { id: "s9" })).toEqual([]);
|
|
19
|
+
expect(exampleRowsFor({}, { id: "s1" })).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("names the distinct steps a recording showed, once each, in order", () => {
|
|
23
|
+
expect(stepsShownBy(example("a", "s2", "s1", "s2"))).toEqual(["s2", "s1"]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("says milliseconds the way a reader does", () => {
|
|
27
|
+
expect(formatMs(0.42)).toBe("0.42 ms");
|
|
28
|
+
expect(formatMs(2.6)).toBe("2.6 ms");
|
|
29
|
+
expect(formatMs(41.39)).toBe("41 ms");
|
|
30
|
+
expect(formatMs(1300)).toBe("1.3 s");
|
|
31
|
+
expect(formatMs(12_000)).toBe("12 s");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What the recordings said about one step.
|
|
2
|
+
//
|
|
3
|
+
// A flow carries its examples whole - one per trace, in the order the trace
|
|
4
|
+
// ran - and a step's panel wants the other cut: every trace that showed this
|
|
5
|
+
// step, with what the span said. This is that cut, kept apart from the panel
|
|
6
|
+
// so a test can hold it.
|
|
7
|
+
|
|
8
|
+
import type { ExampleStep, Flow, FlowExample, Step } from "../catalog";
|
|
9
|
+
|
|
10
|
+
export interface ExampleRow {
|
|
11
|
+
example: FlowExample;
|
|
12
|
+
shown: ExampleStep;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every recorded run of the step, in the order the flow keeps its examples. */
|
|
16
|
+
export function exampleRowsFor(
|
|
17
|
+
flow: Pick<Flow, "examples">,
|
|
18
|
+
step: Pick<Step, "id">,
|
|
19
|
+
): ExampleRow[] {
|
|
20
|
+
return (flow.examples ?? []).flatMap((example) =>
|
|
21
|
+
example.steps
|
|
22
|
+
.filter((shown) => shown.step === step.id)
|
|
23
|
+
.map((shown) => ({ example, shown })),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Milliseconds the way a reader says them: 0.4 ms, 12 ms, 1.3 s. */
|
|
28
|
+
export function formatMs(ms: number): string {
|
|
29
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)} s`;
|
|
30
|
+
if (ms >= 10) return `${Math.round(ms)} ms`;
|
|
31
|
+
return `${ms.toFixed(ms >= 1 ? 1 : 2)} ms`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The distinct steps a recording showed, in the order it showed them. */
|
|
35
|
+
export function stepsShownBy(example: Pick<FlowExample, "steps">): string[] {
|
|
36
|
+
return [...new Set(example.steps.map((shown) => shown.step))];
|
|
37
|
+
}
|
package/src/flow/outline.test.ts
CHANGED
|
@@ -198,6 +198,34 @@ describe("buildOutline", () => {
|
|
|
198
198
|
expect(outlineSteps(rows).every((r) => !r.offPath)).toBe(true);
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
it("gives a counted branch a row even with nothing in it, and says how many went that way", () => {
|
|
202
|
+
const seen = (id: string) => ({ type: "step" as const, id, from: "client", to: "auth.auth", kind: "rpc" as const, label: id, status: "verified" as const, seen: { traces: 1 } });
|
|
203
|
+
const parted: Flow = {
|
|
204
|
+
...checkout,
|
|
205
|
+
steps: [
|
|
206
|
+
seen("s1"),
|
|
207
|
+
{
|
|
208
|
+
type: "alt",
|
|
209
|
+
id: "seen-alt1",
|
|
210
|
+
branches: [
|
|
211
|
+
{ title: "getUser", steps: [seen("seen1")], seen: { traces: 1 } },
|
|
212
|
+
{ title: "otherwise", steps: [], seen: { traces: 2 } },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
type: "alt",
|
|
217
|
+
id: "alt2",
|
|
218
|
+
branches: [
|
|
219
|
+
{ title: "blocked", steps: [seen("s9")] },
|
|
220
|
+
{ title: "otherwise", steps: [] },
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
};
|
|
225
|
+
const titles = frames(buildOutline(parted, NO_FILTER)).map((f) => `${f.keyword} ${f.title}`);
|
|
226
|
+
expect(titles).toEqual(["alt getUser · 1 recording", "else otherwise · 2 recordings", "alt blocked"]);
|
|
227
|
+
});
|
|
228
|
+
|
|
201
229
|
it("emits nothing but steps for a flow with no frames", () => {
|
|
202
230
|
const flat = catalog.flows.find(
|
|
203
231
|
(f) =>
|
package/src/flow/outline.ts
CHANGED
|
@@ -125,13 +125,18 @@ export function buildOutline(
|
|
|
125
125
|
let opened = false;
|
|
126
126
|
bodies.forEach((body, i) => {
|
|
127
127
|
const branch = node.branches[i];
|
|
128
|
-
|
|
128
|
+
// A declared branch with nothing in it is the code's way of
|
|
129
|
+
// saying "or not", and needs no row. A counted one - the
|
|
130
|
+
// recordings that went no further - is a fact worth a row.
|
|
131
|
+
if (!branch || (body.length === 0 && !branch.seen)) return;
|
|
129
132
|
rows.push({
|
|
130
133
|
type: "frame",
|
|
131
134
|
key: `${node.id}:${i}`,
|
|
132
135
|
depth,
|
|
133
136
|
keyword: opened ? "else" : "alt",
|
|
134
|
-
title: branch.
|
|
137
|
+
title: branch.seen
|
|
138
|
+
? `${branch.title} · ${branch.seen.traces} ${branch.seen.traces === 1 ? "recording" : "recordings"}`
|
|
139
|
+
: branch.title,
|
|
135
140
|
terminal: branch.terminal,
|
|
136
141
|
offPath: false,
|
|
137
142
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { forgetTraceTrial, recallTraceTrial, rememberTraceTrial } from "./trace-trial-resume";
|
|
4
|
+
|
|
5
|
+
function fakeStorage() {
|
|
6
|
+
const map = new Map<string, string>();
|
|
7
|
+
return {
|
|
8
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
9
|
+
setItem: (key: string, value: string) => { map.set(key, value); },
|
|
10
|
+
removeItem: (key: string) => { map.delete(key); },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("a recording's trial, remembered across a reload", () => {
|
|
15
|
+
it("is picked up by the page that was watching it, and by no other", () => {
|
|
16
|
+
const storage = fakeStorage();
|
|
17
|
+
rememberTraceTrial(storage, "auth-login", { runId: "r1", writeRunId: null });
|
|
18
|
+
expect(recallTraceTrial(storage, "auth-login")).toEqual({ runId: "r1", writeRunId: null });
|
|
19
|
+
expect(recallTraceTrial(storage, "settings")).toBeNull();
|
|
20
|
+
|
|
21
|
+
rememberTraceTrial(storage, "auth-login", { runId: "r1", writeRunId: "w1" });
|
|
22
|
+
expect(recallTraceTrial(storage, "auth-login")).toEqual({ runId: "r1", writeRunId: "w1" });
|
|
23
|
+
|
|
24
|
+
forgetTraceTrial(storage);
|
|
25
|
+
expect(recallTraceTrial(storage, "auth-login")).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("ignores what it cannot read, and survives having nowhere to write", () => {
|
|
29
|
+
const storage = fakeStorage();
|
|
30
|
+
storage.setItem("portolan.trace-trial.v1", "not json");
|
|
31
|
+
expect(recallTraceTrial(storage, "x")).toBeNull();
|
|
32
|
+
storage.setItem("portolan.trace-trial.v1", JSON.stringify({ scope: "x", runId: 3 }));
|
|
33
|
+
expect(recallTraceTrial(storage, "x")).toBeNull();
|
|
34
|
+
|
|
35
|
+
expect(() => rememberTraceTrial(null, "x", { runId: "r", writeRunId: null })).not.toThrow();
|
|
36
|
+
expect(recallTraceTrial(null, "x")).toBeNull();
|
|
37
|
+
const broken = { getItem: () => { throw new Error("no"); }, setItem: () => { throw new Error("no"); }, removeItem: () => { throw new Error("no"); } };
|
|
38
|
+
expect(() => rememberTraceTrial(broken, "x", { runId: "r", writeRunId: null })).not.toThrow();
|
|
39
|
+
expect(recallTraceTrial(broken, "x")).toBeNull();
|
|
40
|
+
expect(() => forgetTraceTrial(broken)).not.toThrow();
|
|
41
|
+
});
|
|
42
|
+
});
|