@contrail/cli 3.5.0-alpha.7efeaa8 → 3.5.0-alpha.9d881a5
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 +1 -1
- package/dist/api/dash0/types.d.ts +2 -0
- package/dist/commands/event-workflows/assess-impact.js +3 -0
- package/dist/helpers/event-workflow-impact/aggregate.d.ts +9 -0
- package/dist/helpers/event-workflow-impact/aggregate.js +17 -1
- package/dist/helpers/event-workflow-impact/aggregate.spec.js +27 -1
- package/dist/helpers/event-workflow-impact/classify.d.ts +1 -1
- package/dist/helpers/event-workflow-impact/classify.js +10 -3
- package/dist/helpers/event-workflow-impact/classify.spec.js +22 -2
- package/dist/helpers/event-workflow-impact/render.d.ts +2 -1
- package/dist/helpers/event-workflow-impact/render.js +17 -2
- package/dist/helpers/event-workflow-impact/subtree.js +21 -4
- package/dist/helpers/event-workflow-impact/subtree.spec.js +25 -13
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ $ npm install -g @contrail/cli
|
|
|
17
17
|
$ contrail COMMAND
|
|
18
18
|
running command...
|
|
19
19
|
$ contrail (--version)
|
|
20
|
-
@contrail/cli/3.5.0-alpha.
|
|
20
|
+
@contrail/cli/3.5.0-alpha.9d881a5 linux-x64 node-v24.14.1
|
|
21
21
|
$ contrail --help [COMMAND]
|
|
22
22
|
USAGE
|
|
23
23
|
$ contrail COMMAND
|
|
@@ -68,6 +68,7 @@ class AssessImpact extends basecommand_1.BaseCommand {
|
|
|
68
68
|
}
|
|
69
69
|
this.log(`Got ${traceIds.length} trace(s). Fetching subtrees...`);
|
|
70
70
|
const perTraceDescendants = [];
|
|
71
|
+
const actionDurationsNs = [];
|
|
71
72
|
let dropped = 0;
|
|
72
73
|
let totalOrphans = 0;
|
|
73
74
|
let preciseRootCount = 0;
|
|
@@ -79,6 +80,7 @@ class AssessImpact extends basecommand_1.BaseCommand {
|
|
|
79
80
|
continue;
|
|
80
81
|
}
|
|
81
82
|
perTraceDescendants.push(result.descendants);
|
|
83
|
+
actionDurationsNs.push(result.actionSpan.durationNs);
|
|
82
84
|
totalOrphans += result.orphanCount;
|
|
83
85
|
if (result.preciseRoot)
|
|
84
86
|
preciseRootCount++;
|
|
@@ -98,6 +100,7 @@ class AssessImpact extends basecommand_1.BaseCommand {
|
|
|
98
100
|
tracesDropped: dropped,
|
|
99
101
|
totalOrphanCount: totalOrphans,
|
|
100
102
|
preciseRootCount,
|
|
103
|
+
actionDuration: (0, aggregate_1.summarizeDurations)(actionDurationsNs),
|
|
101
104
|
}));
|
|
102
105
|
}
|
|
103
106
|
}
|
|
@@ -21,3 +21,12 @@ export interface AggregateResult {
|
|
|
21
21
|
}
|
|
22
22
|
export declare function aggregate(input: AggregateInput): AggregateResult;
|
|
23
23
|
export declare function percentile(values: number[], p: number): number;
|
|
24
|
+
export interface DurationStats {
|
|
25
|
+
count: number;
|
|
26
|
+
meanMs: number;
|
|
27
|
+
p50Ms: number;
|
|
28
|
+
p95Ms: number;
|
|
29
|
+
p99Ms: number;
|
|
30
|
+
maxMs: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function summarizeDurations(durationsNs: bigint[]): DurationStats;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.percentile = exports.aggregate = void 0;
|
|
3
|
+
exports.summarizeDurations = exports.percentile = exports.aggregate = void 0;
|
|
4
4
|
const classify_1 = require("./classify");
|
|
5
5
|
const keyId = (k) => `${k.category}\x1f${k.service}\x1f${k.operation}`;
|
|
6
6
|
function aggregate(input) {
|
|
@@ -47,3 +47,19 @@ function percentile(values, p) {
|
|
|
47
47
|
return sorted[idx];
|
|
48
48
|
}
|
|
49
49
|
exports.percentile = percentile;
|
|
50
|
+
function summarizeDurations(durationsNs) {
|
|
51
|
+
const ms = durationsNs.map((n) => Number(n / 1000000n));
|
|
52
|
+
if (ms.length === 0) {
|
|
53
|
+
return { count: 0, meanMs: 0, p50Ms: 0, p95Ms: 0, p99Ms: 0, maxMs: 0 };
|
|
54
|
+
}
|
|
55
|
+
const sum = ms.reduce((a, b) => a + b, 0);
|
|
56
|
+
return {
|
|
57
|
+
count: ms.length,
|
|
58
|
+
meanMs: sum / ms.length,
|
|
59
|
+
p50Ms: percentile(ms, 0.5),
|
|
60
|
+
p95Ms: percentile(ms, 0.95),
|
|
61
|
+
p99Ms: percentile(ms, 0.99),
|
|
62
|
+
maxMs: ms.reduce((a, b) => Math.max(a, b), 0),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
exports.summarizeDurations = summarizeDurations;
|
|
@@ -13,6 +13,7 @@ function clientHttp(target, service = 'contrail-items') {
|
|
|
13
13
|
durationNs: 0n,
|
|
14
14
|
attrs: { 'http.request.method': 'GET', 'http.target': target },
|
|
15
15
|
resourceAttrs: { 'service.name': service },
|
|
16
|
+
depthFromAction: 1,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
function dynamoQuery() {
|
|
@@ -69,7 +70,7 @@ describe('aggregate', () => {
|
|
|
69
70
|
];
|
|
70
71
|
const result = (0, aggregate_1.aggregate)({ perTraceDescendants: perTrace });
|
|
71
72
|
expect(result.traceCount).toBe(4);
|
|
72
|
-
const items = result.buckets.find((b) => b.key.category === '
|
|
73
|
+
const items = result.buckets.find((b) => b.key.category === 'http_direct' && b.key.operation === 'GET /api/items');
|
|
73
74
|
expect(items).toBeDefined();
|
|
74
75
|
// counts vector [2,4,0,1] sorted [0,1,2,4]; nearest-rank p50=1, p95=4.
|
|
75
76
|
expect(items.median).toBe(1);
|
|
@@ -106,3 +107,28 @@ describe('aggregate', () => {
|
|
|
106
107
|
expect(result.buckets).toHaveLength(0);
|
|
107
108
|
});
|
|
108
109
|
});
|
|
110
|
+
describe('summarizeDurations', () => {
|
|
111
|
+
it('returns zeros for empty input', () => {
|
|
112
|
+
expect((0, aggregate_1.summarizeDurations)([])).toEqual({
|
|
113
|
+
count: 0,
|
|
114
|
+
meanMs: 0,
|
|
115
|
+
p50Ms: 0,
|
|
116
|
+
p95Ms: 0,
|
|
117
|
+
p99Ms: 0,
|
|
118
|
+
maxMs: 0,
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
it('converts ns to ms and computes mean + nearest-rank percentiles', () => {
|
|
122
|
+
// 10 values: 100ms, 200ms, ..., 1000ms.
|
|
123
|
+
const ns = [];
|
|
124
|
+
for (let i = 1; i <= 10; i++)
|
|
125
|
+
ns.push(BigInt(i * 100) * 1000000n);
|
|
126
|
+
const stats = (0, aggregate_1.summarizeDurations)(ns);
|
|
127
|
+
expect(stats.count).toBe(10);
|
|
128
|
+
expect(stats.meanMs).toBe(550); // sum=5500, n=10
|
|
129
|
+
expect(stats.p50Ms).toBe(500); // ceil(0.5*10)-1 = 4 → sorted[4] = 500
|
|
130
|
+
expect(stats.p95Ms).toBe(1000); // ceil(0.95*10)-1 = 9 → sorted[9] = 1000
|
|
131
|
+
expect(stats.p99Ms).toBe(1000);
|
|
132
|
+
expect(stats.maxMs).toBe(1000);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FlatSpan } from '../../api/dash0/types';
|
|
2
|
-
export type Category = '
|
|
2
|
+
export type Category = 'http_direct' | 'http_transitive' | 'opensearch' | 'dynamodb' | 's3' | 'timestream' | 'lambda' | 'other';
|
|
3
3
|
export declare const CATEGORY_ORDER: Category[];
|
|
4
4
|
export declare const CATEGORY_LABEL: Record<Category, string>;
|
|
5
5
|
export interface Classified {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.normalizeRouteName = exports.looksLikeId = exports.templatePath = exports.classifySpan = exports.CATEGORY_LABEL = exports.CATEGORY_ORDER = void 0;
|
|
4
4
|
exports.CATEGORY_ORDER = [
|
|
5
|
-
'
|
|
5
|
+
'http_direct',
|
|
6
|
+
'http_transitive',
|
|
6
7
|
'opensearch',
|
|
7
8
|
'dynamodb',
|
|
8
9
|
's3',
|
|
@@ -11,7 +12,8 @@ exports.CATEGORY_ORDER = [
|
|
|
11
12
|
'other',
|
|
12
13
|
];
|
|
13
14
|
exports.CATEGORY_LABEL = {
|
|
14
|
-
|
|
15
|
+
http_direct: 'HTTP — direct (called by the action)',
|
|
16
|
+
http_transitive: 'HTTP — transitive (called by downstream services)',
|
|
15
17
|
opensearch: 'OpenSearch',
|
|
16
18
|
dynamodb: 'DynamoDB',
|
|
17
19
|
s3: 'S3',
|
|
@@ -61,8 +63,13 @@ function classifySpan(span) {
|
|
|
61
63
|
// when we have a path; otherwise normalize the leading verb in name so
|
|
62
64
|
// "get /x" and "GET /x" don't split into two buckets.
|
|
63
65
|
const operation = path ? `${method} ${path}` : normalizeRouteName(name);
|
|
66
|
+
// Direct HTTP = called by the action's own code (no SERVER boundary
|
|
67
|
+
// between this span and the action root). Transitive = inside a service
|
|
68
|
+
// the action invoked (1+ SERVER ancestors). Spans without subtree info
|
|
69
|
+
// (e.g. unit tests) default to direct.
|
|
70
|
+
const serverAncestors = span.serverAncestorCount ?? 0;
|
|
64
71
|
return {
|
|
65
|
-
category: '
|
|
72
|
+
category: serverAncestors === 0 ? 'http_direct' : 'http_transitive',
|
|
66
73
|
service: service || 'unknown',
|
|
67
74
|
operation,
|
|
68
75
|
};
|
|
@@ -67,7 +67,9 @@ describe('classifySpan', () => {
|
|
|
67
67
|
it('classifies Timestream AWS SDK spans', () => {
|
|
68
68
|
expect((0, classify_1.classifySpan)(span({ name: 'TimestreamWrite.WriteRecords' }))?.category).toBe('timestream');
|
|
69
69
|
});
|
|
70
|
-
it('classifies
|
|
70
|
+
it('classifies HTTP with no SERVER ancestor as http_direct', () => {
|
|
71
|
+
// Apps lambda's outbound HTTP CLIENT — sits below the action span via a
|
|
72
|
+
// lambda-invoke CLIENT, no SERVER boundary in between.
|
|
71
73
|
const s = span({
|
|
72
74
|
name: 'GET',
|
|
73
75
|
attrs: {
|
|
@@ -76,13 +78,29 @@ describe('classifySpan', () => {
|
|
|
76
78
|
'http.host': 'api.vibeiq.com:443',
|
|
77
79
|
},
|
|
78
80
|
resourceAttrs: { 'service.name': 'contrail-event-workflow-apps' },
|
|
81
|
+
serverAncestorCount: 0,
|
|
79
82
|
});
|
|
80
83
|
expect((0, classify_1.classifySpan)(s)).toEqual({
|
|
81
|
-
category: '
|
|
84
|
+
category: 'http_direct',
|
|
82
85
|
service: 'contrail-event-workflow-apps',
|
|
83
86
|
operation: 'GET /prod/api/items',
|
|
84
87
|
});
|
|
85
88
|
});
|
|
89
|
+
it('classifies HTTP behind a SERVER boundary as http_transitive', () => {
|
|
90
|
+
// Outbound HTTP from a downstream service the action called — there is a
|
|
91
|
+
// SERVER span between this and the action.
|
|
92
|
+
const s = span({
|
|
93
|
+
name: 'GET',
|
|
94
|
+
attrs: {
|
|
95
|
+
'http.request.method': 'GET',
|
|
96
|
+
'http.target': '/prod/api/open-search',
|
|
97
|
+
'http.host': 'api.vibeiq.com:443',
|
|
98
|
+
},
|
|
99
|
+
resourceAttrs: { 'service.name': 'contrail-items' },
|
|
100
|
+
serverAncestorCount: 1,
|
|
101
|
+
});
|
|
102
|
+
expect((0, classify_1.classifySpan)(s)?.category).toBe('http_transitive');
|
|
103
|
+
});
|
|
86
104
|
it('classifies lambda-to-lambda invokes (no HTTP attrs, aws.lambda resource)', () => {
|
|
87
105
|
const s = span({
|
|
88
106
|
name: 'contrail-items-prod-api',
|
|
@@ -109,8 +127,10 @@ describe('classifySpan', () => {
|
|
|
109
127
|
name: 'get /prod/api/custom-entities',
|
|
110
128
|
attrs: { 'http.host': 'api.vibeiq.com:443' },
|
|
111
129
|
resourceAttrs: { 'service.name': 'contrail-custom-entities' },
|
|
130
|
+
serverAncestorCount: 1,
|
|
112
131
|
});
|
|
113
132
|
expect((0, classify_1.classifySpan)(lower)?.operation).toBe('GET /prod/api/custom-entities');
|
|
133
|
+
expect((0, classify_1.classifySpan)(lower)?.category).toBe('http_transitive');
|
|
114
134
|
});
|
|
115
135
|
it('falls back to "other" for unknown CLIENT spans without aws.lambda resource', () => {
|
|
116
136
|
expect((0, classify_1.classifySpan)(span({ name: 'mystery-step' }))?.category).toBe('other');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AggregateResult } from './aggregate';
|
|
1
|
+
import { AggregateResult, DurationStats } from './aggregate';
|
|
2
2
|
export interface RenderOptions {
|
|
3
3
|
identifier: string;
|
|
4
4
|
dataset: string;
|
|
@@ -8,5 +8,6 @@ export interface RenderOptions {
|
|
|
8
8
|
tracesDropped: number;
|
|
9
9
|
totalOrphanCount: number;
|
|
10
10
|
preciseRootCount: number;
|
|
11
|
+
actionDuration: DurationStats;
|
|
11
12
|
}
|
|
12
13
|
export declare function renderReport(result: AggregateResult, opts: RenderOptions): string;
|
|
@@ -15,7 +15,15 @@ function renderReport(result, opts) {
|
|
|
15
15
|
lines.push(`Rooting: ${opts.preciseRootCount} precise / ${fallback} fallback (no inner action span — framework AWS-SDK direct children stripped heuristically)`);
|
|
16
16
|
}
|
|
17
17
|
lines.push('');
|
|
18
|
-
|
|
18
|
+
const d = opts.actionDuration;
|
|
19
|
+
if (d.count > 0) {
|
|
20
|
+
const note = opts.preciseRootCount < opts.samplesObtained
|
|
21
|
+
? ' (includes some framework overhead on fallback-rooted traces)'
|
|
22
|
+
: '';
|
|
23
|
+
lines.push(`Action duration: mean ${fmtMs(d.meanMs)} · p50 ${fmtMs(d.p50Ms)} · p95 ${fmtMs(d.p95Ms)} · p99 ${fmtMs(d.p99Ms)} · max ${fmtMs(d.maxMs)}${note}`);
|
|
24
|
+
lines.push('');
|
|
25
|
+
}
|
|
26
|
+
lines.push('Per-invocation downstream calls. "% of traces" is how often the bucket appears in any invocation; median/p95/max include zeros.');
|
|
19
27
|
lines.push('');
|
|
20
28
|
const byCategory = new Map();
|
|
21
29
|
for (const b of result.buckets) {
|
|
@@ -31,7 +39,7 @@ function renderReport(result, opts) {
|
|
|
31
39
|
any = true;
|
|
32
40
|
lines.push(classify_1.CATEGORY_LABEL[cat]);
|
|
33
41
|
const table = new cli_table3_1.default({
|
|
34
|
-
head: ['service', 'operation', 'median', 'p95', 'max', '
|
|
42
|
+
head: ['service', 'operation', 'median', 'p95', 'max', '% of traces'],
|
|
35
43
|
style: { head: [], border: [] },
|
|
36
44
|
});
|
|
37
45
|
buckets
|
|
@@ -60,3 +68,10 @@ exports.renderReport = renderReport;
|
|
|
60
68
|
function fmt(n) {
|
|
61
69
|
return Number.isInteger(n) ? n.toString() : n.toFixed(1);
|
|
62
70
|
}
|
|
71
|
+
function fmtMs(ms) {
|
|
72
|
+
if (ms < 1)
|
|
73
|
+
return `${ms.toFixed(2)}ms`;
|
|
74
|
+
if (ms < 1000)
|
|
75
|
+
return `${Math.round(ms)}ms`;
|
|
76
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
77
|
+
}
|
|
@@ -49,9 +49,17 @@ function extractActionSubtree(spans, identifier) {
|
|
|
49
49
|
return null;
|
|
50
50
|
const preciseRoot = !!inner;
|
|
51
51
|
const descendants = [];
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
// BFS tracking depth and "service boundary crossings" level-by-level. A
|
|
53
|
+
// service boundary is a SERVER span (kind=2) that represents code running
|
|
54
|
+
// in another service. The action's own executor SERVER — the SERVER span
|
|
55
|
+
// emitted on the receive side of the lambda invocation that runs the
|
|
56
|
+
// action — is NOT counted, because it represents the action's own
|
|
57
|
+
// execution context, not downstream work.
|
|
58
|
+
const queue = [
|
|
59
|
+
{ spanId: actionSpan.spanId, depth: 0, serverAncestors: 0 },
|
|
60
|
+
];
|
|
61
|
+
while (queue.length) {
|
|
62
|
+
const { spanId: parentId, depth, serverAncestors } = queue.shift();
|
|
55
63
|
const children = byParent.get(parentId) ?? [];
|
|
56
64
|
for (const c of children) {
|
|
57
65
|
// Fallback path: when rooting at the consumer, AWS-SDK spans that are a
|
|
@@ -62,8 +70,17 @@ function extractActionSubtree(spans, identifier) {
|
|
|
62
70
|
if (!preciseRoot && parentId === actionSpan.spanId && isFrameworkAwsSdk(c)) {
|
|
63
71
|
continue;
|
|
64
72
|
}
|
|
73
|
+
c.depthFromAction = depth + 1;
|
|
74
|
+
c.serverAncestorCount = serverAncestors;
|
|
65
75
|
descendants.push(c);
|
|
66
|
-
|
|
76
|
+
// Skip the action's executor SERVER from boundary counting: a SERVER
|
|
77
|
+
// span that is a *direct* child of a non-SERVER action span is the
|
|
78
|
+
// server side of the invocation that runs the action's code itself.
|
|
79
|
+
const isExecutorServer = c.kind === 2 &&
|
|
80
|
+
parentId === actionSpan.spanId &&
|
|
81
|
+
actionSpan.kind !== 2;
|
|
82
|
+
const childServerCount = c.kind === 2 && !isExecutorServer ? serverAncestors + 1 : serverAncestors;
|
|
83
|
+
queue.push({ spanId: c.spanId, depth: depth + 1, serverAncestors: childServerCount });
|
|
67
84
|
}
|
|
68
85
|
}
|
|
69
86
|
let orphanCount = 0;
|
|
@@ -20,30 +20,42 @@ describe('extractActionSubtree', () => {
|
|
|
20
20
|
it('returns null when no candidate exists', () => {
|
|
21
21
|
expect((0, subtree_1.extractActionSubtree)([s({ spanId: 'a' })], IDENT)).toBeNull();
|
|
22
22
|
});
|
|
23
|
-
describe('precise root (inner
|
|
24
|
-
it('
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
23
|
+
describe('precise root (inner action span exists)', () => {
|
|
24
|
+
it('does not count the action executor SERVER as a service boundary', () => {
|
|
25
|
+
// Realistic Lambda shape: action span (kind=CLIENT, the invocation) has
|
|
26
|
+
// a SERVER child representing the receive-side of that invocation —
|
|
27
|
+
// this is the action's own executor lambda. The HTTP outbound from
|
|
28
|
+
// inside that lambda must be classified as direct (serverAncestorCount=0).
|
|
29
|
+
// Only SERVER spans BELOW outbound calls (downstream services) count.
|
|
28
30
|
const spans = [
|
|
29
31
|
s({
|
|
30
32
|
spanId: 'consumer',
|
|
31
33
|
parentSpanId: '',
|
|
32
34
|
name: 'consumer',
|
|
35
|
+
kind: 2,
|
|
33
36
|
attrs: { 'contrail.event_workflow.action.identifier': IDENT },
|
|
34
37
|
}),
|
|
35
|
-
s({ spanId: '
|
|
36
|
-
s
|
|
37
|
-
s({ spanId: '
|
|
38
|
-
|
|
39
|
-
s({ spanId: '
|
|
40
|
-
|
|
38
|
+
s({ spanId: 'inner', parentSpanId: 'consumer', name: IDENT, kind: 3 }),
|
|
39
|
+
// Action's own executor SERVER (apps lambda receive side) — exempt.
|
|
40
|
+
s({ spanId: 'execSrv', parentSpanId: 'inner', name: 'apps-lambda', kind: 2 }),
|
|
41
|
+
// Action code's own outbound HTTP — direct.
|
|
42
|
+
s({ spanId: 'http1', parentSpanId: 'execSrv', name: 'GET', kind: 3 }),
|
|
43
|
+
// Downstream SERVER (e.g. contrail-items receiving the request).
|
|
44
|
+
s({ spanId: 'downSrv', parentSpanId: 'http1', name: 'GET /api/items', kind: 2 }),
|
|
45
|
+
// HTTP outbound from downstream — transitive.
|
|
46
|
+
s({ spanId: 'http2', parentSpanId: 'downSrv', name: 'GET', kind: 3 }),
|
|
41
47
|
];
|
|
42
48
|
const result = (0, subtree_1.extractActionSubtree)(spans, IDENT);
|
|
43
49
|
expect(result).not.toBeNull();
|
|
44
50
|
expect(result.preciseRoot).toBe(true);
|
|
45
|
-
const
|
|
46
|
-
|
|
51
|
+
const find = (id) => result.descendants.find((d) => d.spanId === id);
|
|
52
|
+
// Executor SERVER is exempt: it gets count 0 itself, and its descendants
|
|
53
|
+
// don't see +1 from it.
|
|
54
|
+
expect(find('execSrv').serverAncestorCount).toBe(0);
|
|
55
|
+
expect(find('http1').serverAncestorCount).toBe(0);
|
|
56
|
+
// The downstream SERVER does count for its descendants.
|
|
57
|
+
expect(find('downSrv').serverAncestorCount).toBe(0);
|
|
58
|
+
expect(find('http2').serverAncestorCount).toBe(1);
|
|
47
59
|
});
|
|
48
60
|
});
|
|
49
61
|
describe('fallback root (no inner span)', () => {
|
package/oclif.manifest.json
CHANGED