@percy/cli-command 1.32.6-beta.3 → 1.32.7-beta.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/dist/graphTrace.js +109 -0
- package/dist/graphTraceTemplate.html +304 -0
- package/dist/index.js +1 -0
- package/dist/intelliStory.js +712 -0
- package/dist/lockfileDiff.js +138 -0
- package/package.json +10 -5
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html');
|
|
5
|
+
function templateKindOf(v) {
|
|
6
|
+
if (v.changed) return 'is_relevant';
|
|
7
|
+
switch (v.kind) {
|
|
8
|
+
case 'dependency':
|
|
9
|
+
return 'package';
|
|
10
|
+
case 'component':
|
|
11
|
+
return 'component';
|
|
12
|
+
case 'story':
|
|
13
|
+
return 'story';
|
|
14
|
+
default:
|
|
15
|
+
return 'component';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const KIND_RANK = {
|
|
19
|
+
package: 0,
|
|
20
|
+
component: 1,
|
|
21
|
+
is_relevant: 1,
|
|
22
|
+
story: 2
|
|
23
|
+
};
|
|
24
|
+
function computeLayout(rawVertices, edges, transitiveClosure) {
|
|
25
|
+
const n = rawVertices.length;
|
|
26
|
+
const vertices = rawVertices.map((v, i) => ({
|
|
27
|
+
index: i,
|
|
28
|
+
name: v.file_path,
|
|
29
|
+
kind: v.kind,
|
|
30
|
+
changed: !!v.changed,
|
|
31
|
+
row: 0,
|
|
32
|
+
col: 0
|
|
33
|
+
}));
|
|
34
|
+
const incomingMax = new Array(n).fill(0);
|
|
35
|
+
for (const triple of transitiveClosure) {
|
|
36
|
+
const [u, v, val] = triple;
|
|
37
|
+
if (u === v || val <= 0) continue;
|
|
38
|
+
if (v < 0 || v >= n) continue;
|
|
39
|
+
if (val > incomingMax[v]) incomingMax[v] = val;
|
|
40
|
+
}
|
|
41
|
+
for (let i = 0; i < n; i++) {
|
|
42
|
+
vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1;
|
|
43
|
+
}
|
|
44
|
+
const iterations = n + 2;
|
|
45
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
46
|
+
let changed = false;
|
|
47
|
+
for (const [s, t] of edges) {
|
|
48
|
+
if (s < 0 || s >= n || t < 0 || t >= n) continue;
|
|
49
|
+
if (vertices[s].col < vertices[t].col) continue;
|
|
50
|
+
vertices[t].col = vertices[s].col + 1;
|
|
51
|
+
changed = true;
|
|
52
|
+
}
|
|
53
|
+
if (!changed) break;
|
|
54
|
+
}
|
|
55
|
+
let furthestNonStory = 0;
|
|
56
|
+
for (const v of vertices) {
|
|
57
|
+
if (v.kind === 'story') continue;
|
|
58
|
+
if (v.col > furthestNonStory) furthestNonStory = v.col;
|
|
59
|
+
}
|
|
60
|
+
for (const v of vertices) {
|
|
61
|
+
if (v.kind !== 'story') continue;
|
|
62
|
+
if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1;
|
|
63
|
+
}
|
|
64
|
+
const groups = new Map();
|
|
65
|
+
for (const v of vertices) {
|
|
66
|
+
let list = groups.get(v.col);
|
|
67
|
+
if (!list) groups.set(v.col, list = []);
|
|
68
|
+
list.push(v);
|
|
69
|
+
}
|
|
70
|
+
const rankOf = v => {
|
|
71
|
+
const r = KIND_RANK[templateKindOf(v)];
|
|
72
|
+
/* istanbul ignore next */
|
|
73
|
+
return r === undefined ? 99 : r;
|
|
74
|
+
};
|
|
75
|
+
for (const list of groups.values()) {
|
|
76
|
+
list.sort((a, b) => {
|
|
77
|
+
const ra = rankOf(a);
|
|
78
|
+
const rb = rankOf(b);
|
|
79
|
+
if (ra !== rb) return ra - rb;
|
|
80
|
+
if (a.name < b.name) return -1;
|
|
81
|
+
if (a.name > b.name) return 1;
|
|
82
|
+
return 0;
|
|
83
|
+
});
|
|
84
|
+
list.forEach((v, row) => {
|
|
85
|
+
v.row = row;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return vertices.map(v => ({
|
|
89
|
+
index: v.index,
|
|
90
|
+
name: v.name,
|
|
91
|
+
row: v.row,
|
|
92
|
+
col: v.col,
|
|
93
|
+
kind: templateKindOf(v)
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
const LS = String.fromCharCode(0x2028);
|
|
97
|
+
const PS = String.fromCharCode(0x2029);
|
|
98
|
+
function safeJson(obj) {
|
|
99
|
+
return JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--').replace(/--(!?)>/g, '--$1\\>').split(LS).join('\\u2028').split(PS).join('\\u2029');
|
|
100
|
+
}
|
|
101
|
+
export function renderGraphTraceHtml({
|
|
102
|
+
vertices,
|
|
103
|
+
edges,
|
|
104
|
+
transitiveClosureMatrixSparse
|
|
105
|
+
}) {
|
|
106
|
+
const laidOutVertices = computeLayout(vertices || [], edges || [], transitiveClosureMatrixSparse || []);
|
|
107
|
+
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
108
|
+
return template.replace('__VERTICES_JSON__', safeJson(laidOutVertices)).replace('__EDGES_JSON__', safeJson(edges || [])).replace('__TRANSITIVE_CLOSURE_JSON__', safeJson(transitiveClosureMatrixSparse || []));
|
|
109
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>IntelliStory Graph</title>
|
|
6
|
+
<link
|
|
7
|
+
rel="stylesheet"
|
|
8
|
+
href="https://cdn.jsdelivr.net/npm/drawflow@0.0.60/dist/drawflow.min.css"
|
|
9
|
+
integrity="sha384-IFh+Q6zh+LRcTjqVmAKetdGY59dT485vtvWT5DAKQy8iv5+fYWHXisHP7mFKcFqV"
|
|
10
|
+
crossorigin="anonymous">
|
|
11
|
+
<script
|
|
12
|
+
src="https://cdn.jsdelivr.net/npm/drawflow@0.0.60/dist/drawflow.min.js"
|
|
13
|
+
integrity="sha384-JsnM4f1X8b5e9VbQCj6Bt6840HQNDYVKohzbXJnqOetMno8i6cmKNLVkyK3snqSw"
|
|
14
|
+
crossorigin="anonymous"></script>
|
|
15
|
+
<style>
|
|
16
|
+
html, body {
|
|
17
|
+
margin: 0; padding: 0;
|
|
18
|
+
font-family: sans-serif; background: #fafafa;
|
|
19
|
+
width: 100%; height: 100%; overflow: hidden;
|
|
20
|
+
}
|
|
21
|
+
#drawflow {
|
|
22
|
+
position: fixed;
|
|
23
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
24
|
+
background: #fafafa;
|
|
25
|
+
}
|
|
26
|
+
#legend {
|
|
27
|
+
position: fixed; top: 8px; right: 8px;
|
|
28
|
+
background: rgba(255,255,255,0.95);
|
|
29
|
+
padding: 8px 12px; border: 1px solid #999;
|
|
30
|
+
font-size: 12px; z-index: 100;
|
|
31
|
+
}
|
|
32
|
+
#legend span {
|
|
33
|
+
display: inline-block; width: 12px; height: 12px;
|
|
34
|
+
vertical-align: middle; margin-right: 4px; border: 1px solid #333;
|
|
35
|
+
}
|
|
36
|
+
#controls {
|
|
37
|
+
position: fixed; top: 8px; left: 8px;
|
|
38
|
+
background: rgba(255,255,255,0.95);
|
|
39
|
+
border: 1px solid #999; z-index: 100;
|
|
40
|
+
display: flex; gap: 2px;
|
|
41
|
+
}
|
|
42
|
+
#controls button {
|
|
43
|
+
border: 0; background: transparent;
|
|
44
|
+
padding: 6px 10px; cursor: pointer;
|
|
45
|
+
font-size: 14px; font-family: inherit;
|
|
46
|
+
}
|
|
47
|
+
#controls button:hover { background: #eee; }
|
|
48
|
+
|
|
49
|
+
.drawflow .drawflow-node {
|
|
50
|
+
width: max-content;
|
|
51
|
+
min-width: 0; max-width: none;
|
|
52
|
+
height: auto; min-height: 0;
|
|
53
|
+
padding: 8px 14px;
|
|
54
|
+
white-space: nowrap;
|
|
55
|
+
font-size: 12px;
|
|
56
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
57
|
+
background: #fff;
|
|
58
|
+
border: 2px solid #333;
|
|
59
|
+
border-radius: 4px;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
transition: opacity 150ms;
|
|
62
|
+
}
|
|
63
|
+
.drawflow .drawflow-node .drawflow_content_node {
|
|
64
|
+
width: max-content;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
.drawflow .drawflow-node.package { background: #d4e5f7; border-color: #2a6db5; }
|
|
68
|
+
.drawflow .drawflow-node.component { background: #daf0c1; border-color: #4a8e1f; }
|
|
69
|
+
.drawflow .drawflow-node.story { background: #fce4b6; border-color: #b07310; }
|
|
70
|
+
.drawflow .drawflow-node.is-relevant { background: #d9b3ff; border-color: #6f30b0; }
|
|
71
|
+
|
|
72
|
+
.drawflow .drawflow-node .input,
|
|
73
|
+
.drawflow .drawflow-node .output {
|
|
74
|
+
width: 10px !important;
|
|
75
|
+
height: 10px !important;
|
|
76
|
+
border-width: 1px !important;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.drawflow .connection,
|
|
80
|
+
.drawflow .connection *,
|
|
81
|
+
.drawflow svg.connection,
|
|
82
|
+
.drawflow svg.connection * { pointer-events: none !important; }
|
|
83
|
+
|
|
84
|
+
.drawflow .drawflow-node.is-dim { opacity: 0.25; }
|
|
85
|
+
.drawflow .connection.is-dim { visibility: hidden; }
|
|
86
|
+
|
|
87
|
+
.drawflow .drawflow-node.is-selected {
|
|
88
|
+
box-shadow: 0 0 0 3px #2a6db5, 0 2px 8px rgba(0,0,0,0.2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.measure-node {
|
|
92
|
+
position: absolute;
|
|
93
|
+
top: -9999px; left: -9999px;
|
|
94
|
+
opacity: 0;
|
|
95
|
+
pointer-events: none;
|
|
96
|
+
white-space: nowrap;
|
|
97
|
+
padding: 8px 14px;
|
|
98
|
+
font-size: 12px;
|
|
99
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
100
|
+
border: 2px solid transparent;
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<div id="controls">
|
|
106
|
+
<button id="zoom-in" title="Zoom in">+</button>
|
|
107
|
+
<button id="zoom-out" title="Zoom out">-</button>
|
|
108
|
+
<button id="zoom-reset" title="Reset">reset</button>
|
|
109
|
+
</div>
|
|
110
|
+
<div id="legend">
|
|
111
|
+
<div><span style="background:#d4e5f7"></span>Package Dependency</div>
|
|
112
|
+
<div><span style="background:#daf0c1"></span>Component</div>
|
|
113
|
+
<div><span style="background:#fce4b6"></span>Story</div>
|
|
114
|
+
<div><span style="background:#d9b3ff"></span>Changed file</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div id="drawflow"></div>
|
|
117
|
+
<script type="module">
|
|
118
|
+
const vertices = __VERTICES_JSON__;
|
|
119
|
+
const edges = __EDGES_JSON__;
|
|
120
|
+
const transitive_closure_matrix_sparse = __TRANSITIVE_CLOSURE_JSON__;
|
|
121
|
+
const n = vertices.length;
|
|
122
|
+
|
|
123
|
+
function measureWidth(text) {
|
|
124
|
+
const el = document.createElement('div');
|
|
125
|
+
el.className = 'measure-node';
|
|
126
|
+
el.textContent = text;
|
|
127
|
+
document.body.appendChild(el);
|
|
128
|
+
const w = el.offsetWidth;
|
|
129
|
+
el.remove();
|
|
130
|
+
return w;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PORT_PAD = 60;
|
|
134
|
+
const nodeWidth = vertices.map(v => measureWidth(v.name) + PORT_PAD);
|
|
135
|
+
|
|
136
|
+
const PAD_X = 60, PAD_Y = 60, COL_GAP = 140, ROW_HEIGHT = 70;
|
|
137
|
+
|
|
138
|
+
const colMaxWidth = {};
|
|
139
|
+
for (let i = 0; i < n; i++) {
|
|
140
|
+
const c = vertices[i].col;
|
|
141
|
+
if (!colMaxWidth[c] || nodeWidth[i] > colMaxWidth[c]) colMaxWidth[c] = nodeWidth[i];
|
|
142
|
+
}
|
|
143
|
+
const sortedCols = Object.keys(colMaxWidth).map(Number).sort((a, b) => a - b);
|
|
144
|
+
const colX = {};
|
|
145
|
+
let cursor = PAD_X;
|
|
146
|
+
for (const c of sortedCols) {
|
|
147
|
+
colX[c] = cursor;
|
|
148
|
+
cursor += colMaxWidth[c] + COL_GAP;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const container = document.getElementById('drawflow');
|
|
152
|
+
const editor = new Drawflow(container);
|
|
153
|
+
editor.reroute = false;
|
|
154
|
+
editor.start();
|
|
155
|
+
|
|
156
|
+
function escapeHtml(s) {
|
|
157
|
+
return String(s)
|
|
158
|
+
.replace(/&/g, '&')
|
|
159
|
+
.replace(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"')
|
|
162
|
+
.replace(/'/g, ''');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cssClassFor(kind) {
|
|
166
|
+
if (kind === 'is_relevant') return 'is-relevant';
|
|
167
|
+
return kind;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const nodeIds = new Array(n);
|
|
171
|
+
for (let i = 0; i < n; i++) {
|
|
172
|
+
const v = vertices[i];
|
|
173
|
+
const id = editor.addNode(
|
|
174
|
+
'node',
|
|
175
|
+
1, 1,
|
|
176
|
+
colX[v.col],
|
|
177
|
+
PAD_Y + v.row * ROW_HEIGHT,
|
|
178
|
+
cssClassFor(v.kind),
|
|
179
|
+
{ index: v.index, kind: v.kind, name: v.name },
|
|
180
|
+
escapeHtml(v.name),
|
|
181
|
+
);
|
|
182
|
+
nodeIds[i] = id;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const e of edges) {
|
|
186
|
+
editor.addConnection(nodeIds[e[0]], nodeIds[e[1]], 'output_1', 'input_1');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
editor.editor_mode = 'fixed';
|
|
190
|
+
|
|
191
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
192
|
+
function addArrowhead(svg) {
|
|
193
|
+
if (svg.querySelector('marker')) return;
|
|
194
|
+
let defs = svg.querySelector('defs');
|
|
195
|
+
if (!defs) {
|
|
196
|
+
defs = document.createElementNS(SVG_NS, 'defs');
|
|
197
|
+
svg.insertBefore(defs, svg.firstChild);
|
|
198
|
+
}
|
|
199
|
+
const marker = document.createElementNS(SVG_NS, 'marker');
|
|
200
|
+
marker.setAttribute('id', 'arrowhead');
|
|
201
|
+
marker.setAttribute('viewBox', '0 0 10 10');
|
|
202
|
+
marker.setAttribute('refX', '9');
|
|
203
|
+
marker.setAttribute('refY', '5');
|
|
204
|
+
marker.setAttribute('markerWidth', '5');
|
|
205
|
+
marker.setAttribute('markerHeight', '5');
|
|
206
|
+
marker.setAttribute('orient', 'auto');
|
|
207
|
+
const tip = document.createElementNS(SVG_NS, 'path');
|
|
208
|
+
tip.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
|
209
|
+
tip.setAttribute('fill', '#4ea9ff');
|
|
210
|
+
marker.appendChild(tip);
|
|
211
|
+
defs.appendChild(marker);
|
|
212
|
+
const path = svg.querySelector('path.main-path') || svg.querySelector('path');
|
|
213
|
+
if (path) path.setAttribute('marker-end', 'url(#arrowhead)');
|
|
214
|
+
}
|
|
215
|
+
container.querySelectorAll('svg.connection').forEach(addArrowhead);
|
|
216
|
+
|
|
217
|
+
new MutationObserver((muts) => {
|
|
218
|
+
for (const m of muts) {
|
|
219
|
+
for (const node of m.addedNodes) {
|
|
220
|
+
if (node.nodeType !== 1) continue;
|
|
221
|
+
if (node.matches && node.matches('svg.connection')) addArrowhead(node);
|
|
222
|
+
if (node.querySelectorAll) {
|
|
223
|
+
node.querySelectorAll('svg.connection').forEach(addArrowhead);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}).observe(container, { subtree: true, childList: true });
|
|
228
|
+
|
|
229
|
+
const idxByDfId = new Map();
|
|
230
|
+
for (let i = 0; i < n; i++) {
|
|
231
|
+
if (nodeIds[i] !== undefined) idxByDfId.set(nodeIds[i], i);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function connectedTo(start) {
|
|
235
|
+
const seen = new Set([start]);
|
|
236
|
+
for (const transitive_edge of transitive_closure_matrix_sparse) {
|
|
237
|
+
if (transitive_edge[0] === start) seen.add(transitive_edge[1]);
|
|
238
|
+
else if (transitive_edge[1] === start) seen.add(transitive_edge[0]);
|
|
239
|
+
}
|
|
240
|
+
return seen;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseEndpointIds(svg) {
|
|
244
|
+
let outId = null, inId = null;
|
|
245
|
+
for (const cls of svg.classList) {
|
|
246
|
+
if (cls.startsWith('node_out_node-')) outId = parseInt(cls.slice('node_out_node-'.length), 10);
|
|
247
|
+
else if (cls.startsWith('node_in_node-')) inId = parseInt(cls.slice('node_in_node-'.length), 10);
|
|
248
|
+
}
|
|
249
|
+
return { outId, inId };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let currentSelected = null;
|
|
253
|
+
|
|
254
|
+
function clearSelection() {
|
|
255
|
+
container.querySelectorAll('.drawflow-node.is-dim, .drawflow-node.is-selected')
|
|
256
|
+
.forEach(el => el.classList.remove('is-dim', 'is-selected'));
|
|
257
|
+
container.querySelectorAll('svg.connection.is-dim')
|
|
258
|
+
.forEach(el => el.classList.remove('is-dim'));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function applySelection(startIdx) {
|
|
262
|
+
const connected = connectedTo(startIdx);
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < n; i++) {
|
|
265
|
+
const el = document.getElementById('node-' + nodeIds[i]);
|
|
266
|
+
if (!el) continue;
|
|
267
|
+
el.classList.toggle('is-dim', !connected.has(i));
|
|
268
|
+
el.classList.toggle('is-selected', i === startIdx);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
container.querySelectorAll('svg.connection').forEach((svg) => {
|
|
272
|
+
const { outId, inId } = parseEndpointIds(svg);
|
|
273
|
+
const s = idxByDfId.get(outId);
|
|
274
|
+
const t = idxByDfId.get(inId);
|
|
275
|
+
const keep = (s !== undefined && t !== undefined
|
|
276
|
+
&& connected.has(s) && connected.has(t));
|
|
277
|
+
svg.classList.toggle('is-dim', !keep);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
container.addEventListener('click', (evt) => {
|
|
282
|
+
const nodeEl = evt.target.closest('.drawflow-node');
|
|
283
|
+
if (!nodeEl) return;
|
|
284
|
+
const m = nodeEl.id.match(/^node-(\d+)$/);
|
|
285
|
+
if (!m) return;
|
|
286
|
+
const dfId = parseInt(m[1], 10);
|
|
287
|
+
const idx = idxByDfId.get(dfId);
|
|
288
|
+
if (idx === undefined) return;
|
|
289
|
+
|
|
290
|
+
if (currentSelected === idx) {
|
|
291
|
+
currentSelected = null;
|
|
292
|
+
clearSelection();
|
|
293
|
+
} else {
|
|
294
|
+
currentSelected = idx;
|
|
295
|
+
applySelection(idx);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
document.getElementById('zoom-in').onclick = () => editor.zoom_in();
|
|
300
|
+
document.getElementById('zoom-out').onclick = () => editor.zoom_out();
|
|
301
|
+
document.getElementById('zoom-reset').onclick = () => { editor.zoom_reset(); currentSelected = null; clearSelection(); };
|
|
302
|
+
</script>
|
|
303
|
+
</body>
|
|
304
|
+
</html>
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { default, command, _resetShutdownForTest } from './command.js';
|
|
2
2
|
export { legacyCommand, legacyFlags as flags } from './legacy.js';
|
|
3
|
+
export { applyIntelliStory, writeIntelliStoryTrace, IntelliStoryBailError } from './intelliStory.js';
|
|
3
4
|
// export common packages to avoid dependency resolution issues
|
|
4
5
|
export { default as PercyConfig } from '@percy/config';
|
|
5
6
|
export { default as logger } from '@percy/logger';
|
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import globToRegExp from 'glob-to-regexp';
|
|
5
|
+
import logger from '@percy/logger';
|
|
6
|
+
import { diffLockfileDeps } from './lockfileDiff.js';
|
|
7
|
+
import { renderGraphTraceHtml } from './graphTrace.js';
|
|
8
|
+
const NULL_CHAR = String.fromCharCode(0);
|
|
9
|
+
/* istanbul ignore next */
|
|
10
|
+
const stripNull = s => typeof s === 'string' ? s.split(NULL_CHAR).join('') : s;
|
|
11
|
+
const POLL_INTERVAL_MS = 5000;
|
|
12
|
+
const POLL_ATTEMPTS = 12;
|
|
13
|
+
const GLOB_CHARS = /[*?{}[\]]/;
|
|
14
|
+
const MAX_PATTERN_LENGTH = 500;
|
|
15
|
+
|
|
16
|
+
// Share of stats modules that may have unresolved ids before the dependency
|
|
17
|
+
// graph is treated as too incomplete to filter on.
|
|
18
|
+
const MAX_DROPPED_MODULE_RATIO = 0.1;
|
|
19
|
+
const regexCache = new Map();
|
|
20
|
+
function patternToRegex(pattern) {
|
|
21
|
+
/* istanbul ignore next */
|
|
22
|
+
if (typeof pattern !== 'string' || pattern.length > MAX_PATTERN_LENGTH) {
|
|
23
|
+
throw new Error('Invalid pattern: must be a string with max length of 500 characters');
|
|
24
|
+
}
|
|
25
|
+
let regex = regexCache.get(pattern);
|
|
26
|
+
if (!regex) {
|
|
27
|
+
regex = globToRegExp(pattern, {
|
|
28
|
+
extended: true,
|
|
29
|
+
globstar: true
|
|
30
|
+
});
|
|
31
|
+
regexCache.set(pattern, regex);
|
|
32
|
+
}
|
|
33
|
+
return regex;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Callers must have cleared `pattern` through `patternError` first -- a glob that
|
|
37
|
+
// fails to compile is reported there (bail for `bailOnChanges`, warn-and-skip for
|
|
38
|
+
// `untraced`) rather than being swallowed into a silent non-match here.
|
|
39
|
+
function matchesPattern(str, pattern) {
|
|
40
|
+
if (GLOB_CHARS.test(pattern)) return patternToRegex(pattern).test(str);
|
|
41
|
+
return str === pattern;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Returns the compile error for a glob, or null when it is usable. Patterns are
|
|
45
|
+
// validated up front rather than relying on `matchesPattern` swallowing the
|
|
46
|
+
// throw, so a typo is reported even when nothing happens to be compared to it.
|
|
47
|
+
function patternError(pattern) {
|
|
48
|
+
if (!GLOB_CHARS.test(pattern)) return null;
|
|
49
|
+
try {
|
|
50
|
+
patternToRegex(pattern);
|
|
51
|
+
return null;
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return e;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ROOT_DIR_TOKEN = '<rootDir>/';
|
|
57
|
+
|
|
58
|
+
// `git diff --name-only` always emits repo-root-relative paths, whatever the
|
|
59
|
+
// cwd. User patterns are interpreted relative to the invocation directory --
|
|
60
|
+
// the same basis as the `importPath` normalization in `applyIntelliStory`, and
|
|
61
|
+
// the basis a user editing `.percy.yml` beside their config files expects -- so
|
|
62
|
+
// they have to be rebased onto the repo root before they can match. A
|
|
63
|
+
// `<rootDir>/` prefix opts out and anchors the pattern to the repo root.
|
|
64
|
+
//
|
|
65
|
+
// `rebased` is null when the pattern resolves outside the repo, which no diff
|
|
66
|
+
// path can ever match; callers report that rather than matching nothing.
|
|
67
|
+
export function resolvePattern(raw, projectRoot, invocationDir) {
|
|
68
|
+
const toPosix = p => p.split(path.sep).join('/');
|
|
69
|
+
if (raw.startsWith(ROOT_DIR_TOKEN)) {
|
|
70
|
+
return {
|
|
71
|
+
raw,
|
|
72
|
+
rebased: toPosix(raw.slice(ROOT_DIR_TOKEN.length)),
|
|
73
|
+
anchored: true
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const abs = path.isAbsolute(raw) ? raw : path.resolve(invocationDir, raw);
|
|
77
|
+
const rel = path.relative(projectRoot, abs);
|
|
78
|
+
if (!rel || rel.startsWith('..')) return {
|
|
79
|
+
raw,
|
|
80
|
+
rebased: null,
|
|
81
|
+
anchored: false
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
raw,
|
|
85
|
+
rebased: toPosix(rel),
|
|
86
|
+
anchored: false
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Whether `raw` would have matched something under the repo-root reading that
|
|
91
|
+
// the (correct) invocation-relative reading missed. Used to report the one
|
|
92
|
+
// mistake this design can still produce -- a pattern written repo-relative --
|
|
93
|
+
// instead of letting it silently match nothing.
|
|
94
|
+
function rootReadingHit(affectedNodes, {
|
|
95
|
+
raw,
|
|
96
|
+
rebased,
|
|
97
|
+
anchored
|
|
98
|
+
}) {
|
|
99
|
+
if (anchored || raw === rebased || patternError(raw)) return null;
|
|
100
|
+
if (affectedNodes.some(p => matchesPattern(p, rebased))) return null;
|
|
101
|
+
return affectedNodes.find(p => matchesPattern(p, raw)) ?? null;
|
|
102
|
+
}
|
|
103
|
+
function describeInvocation(projectRoot, invocationDir) {
|
|
104
|
+
return path.relative(projectRoot, invocationDir).split(path.sep).join('/') || '.';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// How to spell a repo-root-relative pattern so it means the same thing under
|
|
108
|
+
// the invocation-relative contract. Used only in the "you probably meant the
|
|
109
|
+
// repo root" advice, so it falls back to the `<rootDir>/` form when the target
|
|
110
|
+
// is not reachable from the invocation directory without escaping it.
|
|
111
|
+
function suggestRewrite(raw, projectRoot, invocationDir) {
|
|
112
|
+
const rel = path.relative(invocationDir, path.resolve(projectRoot, raw)).split(path.sep).join('/');
|
|
113
|
+
const anchored = `"${ROOT_DIR_TOKEN}${raw}"`;
|
|
114
|
+
if (!rel || rel.startsWith('..')) return anchored;
|
|
115
|
+
return `"${rel}" (relative to the invocation directory) or ${anchored}`;
|
|
116
|
+
}
|
|
117
|
+
export class IntelliStoryBailError extends Error {
|
|
118
|
+
constructor(message) {
|
|
119
|
+
super(message);
|
|
120
|
+
this.name = 'IntelliStoryBailError';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function git(args) {
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = spawnSync('git', args, {
|
|
127
|
+
encoding: 'utf8'
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
/* istanbul ignore next */
|
|
131
|
+
throw new IntelliStoryBailError(`IntelliStory: git ${args.join(' ')} failed to spawn: ${e.message}; running full snapshot set`);
|
|
132
|
+
}
|
|
133
|
+
if (res.status !== 0) {
|
|
134
|
+
/* istanbul ignore next */
|
|
135
|
+
throw new IntelliStoryBailError(`IntelliStory: git ${args.join(' ')} failed: ${res.stderr || res.stdout || `exit ${res.status}`}; running full snapshot set`);
|
|
136
|
+
}
|
|
137
|
+
return res.stdout;
|
|
138
|
+
}
|
|
139
|
+
function assertSafeRef(ref) {
|
|
140
|
+
if (typeof ref !== 'string' || !/^[A-Za-z0-9_./][A-Za-z0-9_./-]*$/.test(ref)) {
|
|
141
|
+
throw new IntelliStoryBailError(`IntelliStory: unsafe baseline ref "${ref}"; running full snapshot set`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function gitDiffNames(ref) {
|
|
145
|
+
assertSafeRef(ref);
|
|
146
|
+
return git(['-c', 'core.quotepath=false', 'diff', '--name-only', ref, 'HEAD', '--']).split('\n').filter(Boolean);
|
|
147
|
+
}
|
|
148
|
+
function gitProjectRoot() {
|
|
149
|
+
return git(['rev-parse', '--show-toplevel']).trim();
|
|
150
|
+
}
|
|
151
|
+
export function getAffectedFileLocations(baseRef, files) {
|
|
152
|
+
assertSafeRef(baseRef);
|
|
153
|
+
const diff = git(['-c', 'core.quotepath=false', 'diff', '--unified=0', '--no-color', '--no-renames', baseRef, 'HEAD', '--']);
|
|
154
|
+
const toPosix = p => p.split(path.sep).join('/');
|
|
155
|
+
const indexByPath = new Map(files.map((f, i) => [toPosix(f), i]));
|
|
156
|
+
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
157
|
+
const locations = {};
|
|
158
|
+
let currentIdx;
|
|
159
|
+
for (const line of diff.split('\n')) {
|
|
160
|
+
if (line.startsWith('+++ ')) {
|
|
161
|
+
const p = line.slice(4);
|
|
162
|
+
if (p === '/dev/null') {
|
|
163
|
+
currentIdx = undefined;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
/* istanbul ignore next */
|
|
167
|
+
const rel = p.startsWith('b/') ? p.slice(2) : p;
|
|
168
|
+
currentIdx = indexByPath.get(rel);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (currentIdx === undefined) continue;
|
|
172
|
+
const m = HUNK.exec(line);
|
|
173
|
+
if (!m) continue;
|
|
174
|
+
const start = parseInt(m[1], 10);
|
|
175
|
+
const count = m[2] === undefined ? 1 : parseInt(m[2], 10);
|
|
176
|
+
if (count === 0) continue;
|
|
177
|
+
if (!locations[currentIdx]) locations[currentIdx] = [];
|
|
178
|
+
locations[currentIdx].push([start, start + count - 1]);
|
|
179
|
+
}
|
|
180
|
+
return locations;
|
|
181
|
+
}
|
|
182
|
+
export const DEFAULT_CONFIG_DIR = '.storybook';
|
|
183
|
+
|
|
184
|
+
// Storybook's config directory is configurable (`-c`), so the literal
|
|
185
|
+
// `.storybook` cannot be the only thing recognised. `configDirs` always carries
|
|
186
|
+
// the default alongside any configured directory: recognising one directory too
|
|
187
|
+
// many only costs snapshots, while missing the real one loses comparisons.
|
|
188
|
+
//
|
|
189
|
+
// A single-segment name matches at any depth — a `.storybook` anywhere is a
|
|
190
|
+
// Storybook config directory, including in a sibling package — while a
|
|
191
|
+
// multi-segment path (`config/storybook`) is matched as a path prefix so it
|
|
192
|
+
// cannot also match `config/storybook-old`.
|
|
193
|
+
function isInsideDirs(relPath, dirs) {
|
|
194
|
+
const segs = relPath.split(/[/\\]/).filter(Boolean);
|
|
195
|
+
return dirs.some(dir => {
|
|
196
|
+
const d = dir.split(/[/\\]/).filter(Boolean);
|
|
197
|
+
if (!d.length) return false;
|
|
198
|
+
if (d.length === 1) return segs.includes(d[0]);
|
|
199
|
+
return d.length <= segs.length && d.every((s, i) => segs[i] === s);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
const isExcluded = (relPath, configDirs) => isInsideDirs(relPath, ['node_modules', ...configDirs]);
|
|
203
|
+
function resolveAndIndex(value, fileIndex, projectRoot, configDirs) {
|
|
204
|
+
const clean = stripNull(value);
|
|
205
|
+
if (!path.isAbsolute(clean)) return clean;
|
|
206
|
+
const rel = path.relative(projectRoot, clean);
|
|
207
|
+
if (isExcluded(rel, configDirs)) return rel;
|
|
208
|
+
let idx = fileIndex.get(rel);
|
|
209
|
+
if (idx === undefined) {
|
|
210
|
+
idx = fileIndex.size;
|
|
211
|
+
fileIndex.set(rel, idx);
|
|
212
|
+
}
|
|
213
|
+
return idx;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Returns `{ module }` when the module made it into the graph, or `{ dropped }`
|
|
217
|
+
// with the reason it did not. The two reasons are not equivalent: `excluded` is
|
|
218
|
+
// this module deliberately ignoring node_modules and the Storybook config
|
|
219
|
+
// directory, while `unresolved` is a synthetic or virtual id we could not place
|
|
220
|
+
// on disk — a hole in the graph, and the only one worth counting.
|
|
221
|
+
function transformModule(m, fileIndex, projectRoot, configDirs) {
|
|
222
|
+
const out = {};
|
|
223
|
+
if (m.id != null) out.id = resolveAndIndex(m.id, fileIndex, projectRoot, configDirs);
|
|
224
|
+
if (typeof out.id === 'string') {
|
|
225
|
+
return {
|
|
226
|
+
dropped: path.isAbsolute(stripNull(m.id)) ? 'excluded' : 'unresolved'
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const mapEntry = e => {
|
|
230
|
+
const copy = {
|
|
231
|
+
...e
|
|
232
|
+
};
|
|
233
|
+
if (copy.type === 'src' && typeof copy.source === 'string') {
|
|
234
|
+
copy.source = resolveAndIndex(copy.source, fileIndex, projectRoot, configDirs);
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(copy.loc)) {
|
|
237
|
+
copy.loc = copy.loc.map(l => [l.start, l.end]);
|
|
238
|
+
}
|
|
239
|
+
return copy;
|
|
240
|
+
};
|
|
241
|
+
if (Array.isArray(m.imports)) out.imports = m.imports.map(mapEntry);
|
|
242
|
+
if (Array.isArray(m.passThroughExports)) out.passThroughExports = m.passThroughExports.map(mapEntry);
|
|
243
|
+
if (Array.isArray(m.nonPassThroughExports)) out.nonPassThroughExports = m.nonPassThroughExports;
|
|
244
|
+
return {
|
|
245
|
+
module: out
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function readStats(statsFile, projectRoot, log, configDirs) {
|
|
249
|
+
const fileIndex = new Map();
|
|
250
|
+
const modules = [];
|
|
251
|
+
let stats;
|
|
252
|
+
try {
|
|
253
|
+
stats = JSON.parse(fs.readFileSync(statsFile, 'utf8'));
|
|
254
|
+
} catch (e) {
|
|
255
|
+
throw new IntelliStoryBailError(`IntelliStory: failed to parse stats file ${statsFile}: ${e.message}; running full snapshot set`);
|
|
256
|
+
}
|
|
257
|
+
/* istanbul ignore next */
|
|
258
|
+
const rawModules = stats.modules || [];
|
|
259
|
+
let unresolved = 0;
|
|
260
|
+
let excluded = 0;
|
|
261
|
+
for (const m of rawModules) {
|
|
262
|
+
const {
|
|
263
|
+
module,
|
|
264
|
+
dropped
|
|
265
|
+
} = transformModule(m, fileIndex, projectRoot, configDirs);
|
|
266
|
+
if (module) modules.push(module);else if (dropped === 'unresolved') unresolved++;else excluded++;
|
|
267
|
+
}
|
|
268
|
+
if (excluded) log === null || log === void 0 || log.debug(`IntelliStory: skipped ${excluded} module(s) in node_modules or the Storybook config directory`);
|
|
269
|
+
|
|
270
|
+
// An unresolved module is a missing edge, and a missing edge is exactly what
|
|
271
|
+
// makes the API judge a story unaffected. A few synthetic ids are normal for
|
|
272
|
+
// some builders, so this warns rather than bails -- but past the threshold the
|
|
273
|
+
// graph is degraded enough that filtering on it is not trustworthy. Only
|
|
274
|
+
// resolvable candidates count towards the ratio: deliberately excluded modules
|
|
275
|
+
// are not a gap, and including them would bail on healthy builds whose stats
|
|
276
|
+
// happen to carry a lot of node_modules.
|
|
277
|
+
if (unresolved) {
|
|
278
|
+
// at least `unresolved` modules were candidates, so this cannot divide by zero
|
|
279
|
+
const candidates = rawModules.length - excluded;
|
|
280
|
+
const ratio = unresolved / candidates;
|
|
281
|
+
if (ratio > MAX_DROPPED_MODULE_RATIO) {
|
|
282
|
+
throw new IntelliStoryBailError(`IntelliStory: ${unresolved} of ${candidates} stats modules have unresolved (non-absolute) ids (${Math.round(ratio * 100)}%, limit ${Math.round(MAX_DROPPED_MODULE_RATIO * 100)}%); the dependency graph would be incomplete; running full snapshot set`);
|
|
283
|
+
}
|
|
284
|
+
log === null || log === void 0 || log.warn(`IntelliStory: dropped ${unresolved} of ${candidates} module(s) with unresolved (non-absolute) ids; the dependency graph may be missing edges for them`);
|
|
285
|
+
}
|
|
286
|
+
const files = [...fileIndex.entries()].sort((a, b) => a[1] - b[1]).map(([p]) => p);
|
|
287
|
+
return {
|
|
288
|
+
files,
|
|
289
|
+
modules,
|
|
290
|
+
buildId: stats.buildId
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async function pollGraphStatus(percy, buildId, log) {
|
|
294
|
+
for (let i = 0; i < POLL_ATTEMPTS; i++) {
|
|
295
|
+
const res = await percy.client.getStatus('intelli_story_graph', [buildId]);
|
|
296
|
+
const status = res === null || res === void 0 ? void 0 : res.status;
|
|
297
|
+
log.debug(`IntelliStory: graph status (attempt ${i + 1}) = ${status}`);
|
|
298
|
+
if (status === 'done' || status === 'failed') return {
|
|
299
|
+
status,
|
|
300
|
+
data: res === null || res === void 0 ? void 0 : res.data
|
|
301
|
+
};
|
|
302
|
+
if (i < POLL_ATTEMPTS - 1) await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
|
|
303
|
+
}
|
|
304
|
+
return {
|
|
305
|
+
status: null
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
export async function validateAndReadStats(buildDir, statsFile, projectRoot, log, configDirs = [DEFAULT_CONFIG_DIR]) {
|
|
309
|
+
const statsName = path.basename(statsFile || 'enriched-stats.json');
|
|
310
|
+
if (!/^[\w.-]+\.json$/i.test(statsName)) {
|
|
311
|
+
throw new IntelliStoryBailError(`IntelliStory: invalid statsFile "${statsName}" — must be a .json filename; running full snapshot set`);
|
|
312
|
+
}
|
|
313
|
+
const resolvedStatsPath = path.join(path.resolve(buildDir), statsName);
|
|
314
|
+
let statsStat;
|
|
315
|
+
try {
|
|
316
|
+
statsStat = fs.statSync(resolvedStatsPath);
|
|
317
|
+
} catch {
|
|
318
|
+
throw new IntelliStoryBailError(`IntelliStory: stats file "${statsName}" not found in build directory ${buildDir}; running full snapshot set`);
|
|
319
|
+
}
|
|
320
|
+
if (!statsStat.isFile()) {
|
|
321
|
+
throw new IntelliStoryBailError(`IntelliStory: stats file "${statsName}" in ${buildDir} is not a regular file; running full snapshot set`);
|
|
322
|
+
}
|
|
323
|
+
log.debug(`IntelliStory: parsing stats file ${resolvedStatsPath}`);
|
|
324
|
+
// The graph is now keyed by the Percy build id, not the stats-file `buildId`,
|
|
325
|
+
// so a missing `buildId` in the stats file is no longer fatal. We only need
|
|
326
|
+
// the module graph (`files`/`modules`) from here.
|
|
327
|
+
const {
|
|
328
|
+
files,
|
|
329
|
+
modules
|
|
330
|
+
} = await readStats(resolvedStatsPath, projectRoot, log, configDirs);
|
|
331
|
+
return {
|
|
332
|
+
files,
|
|
333
|
+
modules
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
export async function getBaselineAndAffectedNodes(percy, baseline, log) {
|
|
337
|
+
var _percy$build;
|
|
338
|
+
let baseRef;
|
|
339
|
+
|
|
340
|
+
// Always look up the base build: its `browsers_changed_from_base` flag forces
|
|
341
|
+
// a full snapshot run regardless of whether an explicit baseline was configured.
|
|
342
|
+
const baseLookup = await percy.client.getIntelliStorySnapshotNameToCommit((_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id);
|
|
343
|
+
log.debug(`IntelliStory: base lookup ${JSON.stringify(baseLookup)}`);
|
|
344
|
+
if (baseLookup !== null && baseLookup !== void 0 && baseLookup.browsers_changed_from_base) {
|
|
345
|
+
throw new IntelliStoryBailError('IntelliStory: This build has to take all snapshots by fallback because this build corresponds to a browser upgrade');
|
|
346
|
+
}
|
|
347
|
+
if (baseline) {
|
|
348
|
+
log.debug(`IntelliStory: diffing against explicit baseline "${baseline}"`);
|
|
349
|
+
baseRef = baseline;
|
|
350
|
+
} else {
|
|
351
|
+
if (!(baseLookup !== null && baseLookup !== void 0 && baseLookup.base_build_commit_sha)) {
|
|
352
|
+
throw new IntelliStoryBailError('IntelliStory: API could not predict a base build commit and no explicit baseline was set; running full snapshot set');
|
|
353
|
+
}
|
|
354
|
+
log.debug(`IntelliStory: diffing against predicted base build commit "${baseLookup.base_build_commit_sha}"`);
|
|
355
|
+
baseRef = baseLookup.base_build_commit_sha;
|
|
356
|
+
}
|
|
357
|
+
assertSafeRef(baseRef);
|
|
358
|
+
const affectedNodes = gitDiffNames(baseRef);
|
|
359
|
+
return {
|
|
360
|
+
baseRef,
|
|
361
|
+
affectedNodes
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
export function assertNoDotStorybookChange(affectedNodes, configDirs = [DEFAULT_CONFIG_DIR]) {
|
|
365
|
+
const hit = affectedNodes.find(p => isInsideDirs(p, configDirs));
|
|
366
|
+
if (hit) {
|
|
367
|
+
throw new IntelliStoryBailError(`IntelliStory: change to "${hit}" inside the Storybook config directory affects all stories; running full snapshot set`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The config directory the user gave us, on the same repo-root basis as
|
|
372
|
+
// everything else. Interpreted relative to the invocation directory, matching
|
|
373
|
+
// `bailOnChanges`/`untraced`; the default is kept alongside it so a project that
|
|
374
|
+
// has both a custom directory and a stray `.storybook` is covered either way.
|
|
375
|
+
export function resolveConfigDirs(configDir, projectRoot, invocationDir, log) {
|
|
376
|
+
const dirs = new Set([DEFAULT_CONFIG_DIR]);
|
|
377
|
+
if (!configDir || configDir === DEFAULT_CONFIG_DIR) return [...dirs];
|
|
378
|
+
const {
|
|
379
|
+
rebased
|
|
380
|
+
} = resolvePattern(configDir, projectRoot, invocationDir);
|
|
381
|
+
if (rebased == null) {
|
|
382
|
+
log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" resolves outside the project root; falling back to "${DEFAULT_CONFIG_DIR}"`);
|
|
383
|
+
return [...dirs];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Unlike a glob, a config directory has to exist -- so a directory written on
|
|
387
|
+
// the wrong basis is detectable here rather than silently matching nothing
|
|
388
|
+
// later. When only the repo-root reading exists, recognise it as well:
|
|
389
|
+
// recognising one directory too many costs snapshots, missing the real one
|
|
390
|
+
// loses comparisons.
|
|
391
|
+
if (!fs.existsSync(path.resolve(projectRoot, rebased))) {
|
|
392
|
+
const asRootRelative = configDir.split(path.sep).join('/');
|
|
393
|
+
if (!path.isAbsolute(configDir) && fs.existsSync(path.resolve(projectRoot, configDir))) {
|
|
394
|
+
log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" does not exist relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}") but does relative to the project root; recognising both. Write it relative to the invocation directory to make this explicit.`);
|
|
395
|
+
dirs.add(asRootRelative);
|
|
396
|
+
} else {
|
|
397
|
+
log === null || log === void 0 || log.warn(`IntelliStory: configDir "${configDir}" resolves to "${rebased}", which does not exist; it is interpreted relative to the invocation directory, not the project root`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
dirs.add(rebased);
|
|
401
|
+
return [...dirs];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// `bailOnChanges` is the user's escape hatch for changes the graph cannot
|
|
405
|
+
// reason about, so every failure mode here resolves towards bailing: a pattern
|
|
406
|
+
// that cannot be evaluated is a disabled safety valve, and a pattern that only
|
|
407
|
+
// matches under the repo-root reading still bails (loudly) rather than being
|
|
408
|
+
// treated as a non-match.
|
|
409
|
+
export function assertNoBailOnChanges(affectedNodes, bailOnChanges, {
|
|
410
|
+
projectRoot = process.cwd(),
|
|
411
|
+
invocationDir = projectRoot,
|
|
412
|
+
log
|
|
413
|
+
} = {}) {
|
|
414
|
+
if (!(bailOnChanges !== null && bailOnChanges !== void 0 && bailOnChanges.length)) return;
|
|
415
|
+
for (const raw of bailOnChanges) {
|
|
416
|
+
const resolved = resolvePattern(raw, projectRoot, invocationDir);
|
|
417
|
+
if (resolved.rebased == null) {
|
|
418
|
+
throw new IntelliStoryBailError(`IntelliStory: bailOnChanges pattern "${raw}" resolves outside the project root and can never match a changed file; running full snapshot set`);
|
|
419
|
+
}
|
|
420
|
+
const err = patternError(resolved.rebased);
|
|
421
|
+
if (err) {
|
|
422
|
+
throw new IntelliStoryBailError(`IntelliStory: bailOnChanges pattern "${raw}" is not a valid glob (${err.message}); running full snapshot set`);
|
|
423
|
+
}
|
|
424
|
+
let bailed = affectedNodes.find(p => matchesPattern(p, resolved.rebased));
|
|
425
|
+
if (!bailed) {
|
|
426
|
+
const rootHit = rootReadingHit(affectedNodes, resolved);
|
|
427
|
+
if (!rootHit) continue;
|
|
428
|
+
log === null || log === void 0 || log.warn(`IntelliStory: bailOnChanges pattern "${raw}" matched "${rootHit}" only when read relative to the project root. Patterns are relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}"); bailing anyway, but write it as ${suggestRewrite(raw, projectRoot, invocationDir)} to make this explicit.`);
|
|
429
|
+
bailed = rootHit;
|
|
430
|
+
}
|
|
431
|
+
throw new IntelliStoryBailError(`IntelliStory: change to "${bailed}" matched bailOnChanges; running full snapshot set`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// `untraced` drops files from the affected set, so honouring an ambiguous
|
|
436
|
+
// reading here would remove snapshots -- the opposite direction of harm from
|
|
437
|
+
// `bailOnChanges`. Only the invocation-relative reading is applied; a pattern
|
|
438
|
+
// that looks repo-relative is reported and left unapplied, since keeping a file
|
|
439
|
+
// traced never loses a comparison.
|
|
440
|
+
export function enforceUntraced(affectedNodes, untraced, {
|
|
441
|
+
projectRoot = process.cwd(),
|
|
442
|
+
invocationDir = projectRoot,
|
|
443
|
+
log
|
|
444
|
+
} = {}) {
|
|
445
|
+
if (!(untraced !== null && untraced !== void 0 && untraced.length)) return affectedNodes;
|
|
446
|
+
const resolved = [];
|
|
447
|
+
for (const raw of untraced) {
|
|
448
|
+
const r = resolvePattern(raw, projectRoot, invocationDir);
|
|
449
|
+
if (r.rebased == null) {
|
|
450
|
+
log === null || log === void 0 || log.warn(`IntelliStory: ignoring untraced pattern "${raw}" — it resolves outside the project root and can never match a changed file`);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const err = patternError(r.rebased);
|
|
454
|
+
if (err) {
|
|
455
|
+
log === null || log === void 0 || log.warn(`IntelliStory: ignoring untraced pattern "${raw}" — not a valid glob (${err.message})`);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const rootHit = rootReadingHit(affectedNodes, r);
|
|
459
|
+
if (rootHit) {
|
|
460
|
+
log === null || log === void 0 || log.warn(`IntelliStory: untraced pattern "${raw}" matches "${rootHit}" only when read relative to the project root. Patterns are relative to the invocation directory ("${describeInvocation(projectRoot, invocationDir)}") — not applied; write it as ${suggestRewrite(raw, projectRoot, invocationDir)}.`);
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
resolved.push(r);
|
|
464
|
+
}
|
|
465
|
+
return affectedNodes.filter(p => !resolved.some(({
|
|
466
|
+
rebased
|
|
467
|
+
}) => matchesPattern(p, rebased)));
|
|
468
|
+
}
|
|
469
|
+
export async function getAffectedPackages(affectedNodes, baseRef, projectRoot, log) {
|
|
470
|
+
assertSafeRef(baseRef);
|
|
471
|
+
const MANIFEST_PATHS = new Set(['package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']);
|
|
472
|
+
const manifestHits = affectedNodes.filter(p => MANIFEST_PATHS.has(path.basename(p)));
|
|
473
|
+
if (manifestHits.length === 0) return [];
|
|
474
|
+
const uniqueDirs = [...new Set(manifestHits.map(p => path.dirname(p)))];
|
|
475
|
+
if (uniqueDirs.length > 1) {
|
|
476
|
+
throw new IntelliStoryBailError(`IntelliStory: manifest changes span multiple directories (${uniqueDirs.join(', ')}); running full snapshot set`);
|
|
477
|
+
}
|
|
478
|
+
const manifestDir = uniqueDirs[0];
|
|
479
|
+
const absManifestDir = path.resolve(projectRoot, manifestDir);
|
|
480
|
+
const LOCKFILE_NAMES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
481
|
+
const presentLockfiles = LOCKFILE_NAMES.filter(n => fs.existsSync(path.join(absManifestDir, n)));
|
|
482
|
+
if (presentLockfiles.length === 0) {
|
|
483
|
+
throw new IntelliStoryBailError(`IntelliStory: manifest changed in "${manifestDir}" but no lockfile present there; running full snapshot set`);
|
|
484
|
+
}
|
|
485
|
+
if (presentLockfiles.length > 1) {
|
|
486
|
+
throw new IntelliStoryBailError(`IntelliStory: multiple lockfiles in "${manifestDir}" (${presentLockfiles.join(', ')}); cannot pick canonical; running full snapshot set`);
|
|
487
|
+
}
|
|
488
|
+
const lockfileName = presentLockfiles[0];
|
|
489
|
+
const lockfileRepoPath = manifestDir === '.' ? lockfileName : `${manifestDir}/${lockfileName}`;
|
|
490
|
+
let oldLockfile;
|
|
491
|
+
try {
|
|
492
|
+
oldLockfile = git(['show', `${baseRef}:${lockfileRepoPath}`]);
|
|
493
|
+
} catch {
|
|
494
|
+
throw new IntelliStoryBailError(`IntelliStory: lockfile "${lockfileRepoPath}" not present at base ref ${baseRef}; running full snapshot set`);
|
|
495
|
+
}
|
|
496
|
+
let newLockfile;
|
|
497
|
+
try {
|
|
498
|
+
newLockfile = fs.readFileSync(path.join(absManifestDir, lockfileName), 'utf8');
|
|
499
|
+
} catch {
|
|
500
|
+
throw new IntelliStoryBailError(`IntelliStory: failed to read lockfile "${lockfileName}" in "${manifestDir}"; running full snapshot set`);
|
|
501
|
+
}
|
|
502
|
+
if (oldLockfile === newLockfile) return [];
|
|
503
|
+
let packageJson;
|
|
504
|
+
try {
|
|
505
|
+
packageJson = fs.readFileSync(path.join(absManifestDir, 'package.json'), 'utf8');
|
|
506
|
+
} catch {
|
|
507
|
+
throw new IntelliStoryBailError(`IntelliStory: failed to read "package.json" in "${manifestDir}"; running full snapshot set`);
|
|
508
|
+
}
|
|
509
|
+
const packageJsonRepoPath = manifestDir === '.' ? 'package.json' : `${manifestDir}/package.json`;
|
|
510
|
+
const oldPackageJson = git(['show', `${baseRef}:${packageJsonRepoPath}`]);
|
|
511
|
+
try {
|
|
512
|
+
const packageAffected = await diffLockfileDeps({
|
|
513
|
+
packageJson,
|
|
514
|
+
oldLockfile,
|
|
515
|
+
newLockfile,
|
|
516
|
+
lockfileType: lockfileName,
|
|
517
|
+
oldPackageJson
|
|
518
|
+
});
|
|
519
|
+
/* istanbul ignore next */
|
|
520
|
+
log.debug(`IntelliStory: lockfile diff produced ${packageAffected.length} affected packages: ${packageAffected.join(', ')}`);
|
|
521
|
+
/* istanbul ignore next */
|
|
522
|
+
return packageAffected;
|
|
523
|
+
} catch (e) {
|
|
524
|
+
/* istanbul ignore else */
|
|
525
|
+
if (e.code === 'SNYK_LOCKFILE_PARSER_UNAVAILABLE') {
|
|
526
|
+
throw new IntelliStoryBailError(`IntelliStory: ${e.message}; running full snapshot set`);
|
|
527
|
+
}
|
|
528
|
+
/* istanbul ignore next */
|
|
529
|
+
throw e;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
export function extractStorybookPaths(snapshots, normalizeImportPath, log) {
|
|
533
|
+
const storybookPaths = [...new Set(snapshots.map(s => normalizeImportPath(s.importPath)).filter(Boolean))];
|
|
534
|
+
const snapshotsWithImportPath = snapshots.filter(s => s.importPath).length;
|
|
535
|
+
log.debug(`IntelliStory: ${snapshotsWithImportPath}/${snapshots.length} snapshots have importPath; ${storybookPaths.length} unique storybookPaths`);
|
|
536
|
+
if (storybookPaths.length === 0) {
|
|
537
|
+
var _snapshots$, _snapshots$2, _snapshots$3;
|
|
538
|
+
log.warn(`IntelliStory: no snapshots have importPath set — check Storybook story extraction. Sample snapshot: ${JSON.stringify({
|
|
539
|
+
id: (_snapshots$ = snapshots[0]) === null || _snapshots$ === void 0 ? void 0 : _snapshots$.id,
|
|
540
|
+
name: (_snapshots$2 = snapshots[0]) === null || _snapshots$2 === void 0 ? void 0 : _snapshots$2.name,
|
|
541
|
+
importPath: (_snapshots$3 = snapshots[0]) === null || _snapshots$3 === void 0 ? void 0 : _snapshots$3.importPath,
|
|
542
|
+
keys: snapshots[0] ? Object.keys(snapshots[0]) : []
|
|
543
|
+
})}`);
|
|
544
|
+
} else {
|
|
545
|
+
log.debug(`IntelliStory: storybookPaths sample: ${storybookPaths.slice(0, 3).join(', ')}`);
|
|
546
|
+
}
|
|
547
|
+
return storybookPaths;
|
|
548
|
+
}
|
|
549
|
+
export async function runGraphGeneration(percy, buildId, payload, log) {
|
|
550
|
+
const {
|
|
551
|
+
files,
|
|
552
|
+
modules,
|
|
553
|
+
storybookPaths,
|
|
554
|
+
affectedNodes,
|
|
555
|
+
affectedFileLocations
|
|
556
|
+
} = payload;
|
|
557
|
+
log.debug(`IntelliStory: starting graph generation job ${JSON.stringify({
|
|
558
|
+
buildId,
|
|
559
|
+
files,
|
|
560
|
+
modules,
|
|
561
|
+
storybookPaths,
|
|
562
|
+
affectedNodes,
|
|
563
|
+
affectedFileLocations
|
|
564
|
+
})}`);
|
|
565
|
+
await percy.client.generateIntelliStoryGraph(buildId, {
|
|
566
|
+
files,
|
|
567
|
+
modules,
|
|
568
|
+
storybookPaths,
|
|
569
|
+
affectedNodes,
|
|
570
|
+
affectedFileLocations
|
|
571
|
+
});
|
|
572
|
+
const {
|
|
573
|
+
status
|
|
574
|
+
} = await pollGraphStatus(percy, buildId, log);
|
|
575
|
+
if (status !== 'done') {
|
|
576
|
+
throw new IntelliStoryBailError(`IntelliStory: graph generation did not complete (status: ${status ?? 'timed out'}); running full snapshot set`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
export function maybeWriteTrace(trace, data, log) {
|
|
580
|
+
if (trace && data !== null && data !== void 0 && data.vertices && data !== null && data !== void 0 && data.edges && data !== null && data !== void 0 && data.transitive_closure_matrix_sparse) {
|
|
581
|
+
const tracePath = path.resolve(process.cwd(), 'trace.html');
|
|
582
|
+
try {
|
|
583
|
+
const html = renderGraphTraceHtml({
|
|
584
|
+
vertices: data.vertices,
|
|
585
|
+
edges: data.edges,
|
|
586
|
+
transitiveClosureMatrixSparse: data.transitive_closure_matrix_sparse
|
|
587
|
+
});
|
|
588
|
+
fs.writeFileSync(tracePath, html);
|
|
589
|
+
log.info(`IntelliStory: trace written to ${tracePath}`);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
log.warn(`IntelliStory: failed to write trace.html: ${e.message}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
export async function applyIntelliStory(percy, snapshots, intelliStoryConfig, buildDir) {
|
|
596
|
+
var _percy$build2;
|
|
597
|
+
const log = logger('storybook:intelliStory');
|
|
598
|
+
const {
|
|
599
|
+
baseline,
|
|
600
|
+
untraced,
|
|
601
|
+
bailOnChanges,
|
|
602
|
+
statsFile,
|
|
603
|
+
configDir
|
|
604
|
+
} = intelliStoryConfig || {};
|
|
605
|
+
if (!buildDir) {
|
|
606
|
+
throw new IntelliStoryBailError('IntelliStory requires the Storybook build directory (e.g. `percy storybook ./storybook-static`); URL and `start` modes are not supported. Running full snapshot set');
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// The graph is keyed by the real Percy build id. The build is created up
|
|
610
|
+
// front for IntelliStory runs (see @percy/storybook); if it is not present
|
|
611
|
+
// (e.g. a dry run, or build creation failed) there is nothing to key on.
|
|
612
|
+
const buildId = (_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id;
|
|
613
|
+
if (!buildId) {
|
|
614
|
+
throw new IntelliStoryBailError('IntelliStory: Percy build was not created (dry run or build creation failed); running full snapshot set');
|
|
615
|
+
}
|
|
616
|
+
const projectRoot = gitProjectRoot();
|
|
617
|
+
const invocationDir = process.cwd();
|
|
618
|
+
const patternOpts = {
|
|
619
|
+
projectRoot,
|
|
620
|
+
invocationDir,
|
|
621
|
+
log
|
|
622
|
+
};
|
|
623
|
+
log.debug(`IntelliStory: project root ${projectRoot}, invoked from "${describeInvocation(projectRoot, invocationDir)}" — user patterns and configDir are relative to the invocation directory`);
|
|
624
|
+
const configDirs = resolveConfigDirs(configDir, projectRoot, invocationDir, log);
|
|
625
|
+
log.debug(`IntelliStory: Storybook config directories: ${configDirs.map(d => `"${d}"`).join(', ')}`);
|
|
626
|
+
const {
|
|
627
|
+
files,
|
|
628
|
+
modules
|
|
629
|
+
} = await validateAndReadStats(buildDir, statsFile, projectRoot, log, configDirs);
|
|
630
|
+
let {
|
|
631
|
+
baseRef,
|
|
632
|
+
affectedNodes
|
|
633
|
+
} = await getBaselineAndAffectedNodes(percy, baseline, log);
|
|
634
|
+
assertNoDotStorybookChange(affectedNodes, configDirs);
|
|
635
|
+
assertNoBailOnChanges(affectedNodes, bailOnChanges, patternOpts);
|
|
636
|
+
affectedNodes = enforceUntraced(affectedNodes, untraced, patternOpts);
|
|
637
|
+
const packageAffectedNodes = await getAffectedPackages(affectedNodes, baseRef, projectRoot, log);
|
|
638
|
+
if (!affectedNodes.length && !packageAffectedNodes.length) {
|
|
639
|
+
throw new IntelliStoryBailError('IntelliStory: no affected files or packages detected after filtering; running full snapshot set');
|
|
640
|
+
}
|
|
641
|
+
const dotPosix = './';
|
|
642
|
+
const dotPlatform = `.${path.sep}`;
|
|
643
|
+
const normalizeImportPath = p => {
|
|
644
|
+
if (typeof p !== 'string' || !p) return p;
|
|
645
|
+
let rel = p;
|
|
646
|
+
/* istanbul ignore next */
|
|
647
|
+
if (rel.startsWith(dotPlatform)) rel = rel.slice(dotPlatform.length);else if (rel.startsWith(dotPosix)) rel = rel.slice(dotPosix.length);
|
|
648
|
+
const abs = path.resolve(invocationDir, rel);
|
|
649
|
+
const projRel = path.relative(projectRoot, abs);
|
|
650
|
+
/* istanbul ignore next */
|
|
651
|
+
return projRel || rel;
|
|
652
|
+
};
|
|
653
|
+
const storybookPaths = extractStorybookPaths(snapshots, normalizeImportPath, log);
|
|
654
|
+
|
|
655
|
+
/* istanbul ignore next */
|
|
656
|
+
if (packageAffectedNodes.length) {
|
|
657
|
+
affectedNodes = [...affectedNodes, ...packageAffectedNodes];
|
|
658
|
+
}
|
|
659
|
+
const affectedFileLocations = getAffectedFileLocations(baseRef, files);
|
|
660
|
+
|
|
661
|
+
// Enqueue the affected-story graph against the Percy build. Snapshot
|
|
662
|
+
// selection now happens server-side (when snapshots are posted), so we no
|
|
663
|
+
// longer read affected_stories back here or write the trace — we only kick
|
|
664
|
+
// off generation and surface a failure by bailing to the full set.
|
|
665
|
+
await runGraphGeneration(percy, buildId, {
|
|
666
|
+
files,
|
|
667
|
+
modules,
|
|
668
|
+
storybookPaths,
|
|
669
|
+
affectedNodes,
|
|
670
|
+
affectedFileLocations
|
|
671
|
+
}, log);
|
|
672
|
+
|
|
673
|
+
// Tag every snapshot with `intelliStory` and its normalized `storybookPath`
|
|
674
|
+
// so the API can perform affected-story selection when each is posted. The
|
|
675
|
+
// API is the sole arbiter of what can be skipped — including whether a
|
|
676
|
+
// snapshot has a usable baseline to carry forward.
|
|
677
|
+
//
|
|
678
|
+
// The one exception is a story with no resolvable source path: selection is
|
|
679
|
+
// keyed by that path, so the API rejects `intelli-story` without it (400).
|
|
680
|
+
// Leave those untagged and they are simply captured as normal.
|
|
681
|
+
return snapshots.map(s => {
|
|
682
|
+
const storybookPath = normalizeImportPath(s.importPath);
|
|
683
|
+
return {
|
|
684
|
+
...s,
|
|
685
|
+
intelliStory: !!storybookPath,
|
|
686
|
+
storybookPath
|
|
687
|
+
};
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Called after the build has been finalized. At that point the graph job's
|
|
692
|
+
// data (vertices/edges/transitive closure) is available from job status, so we
|
|
693
|
+
// fetch it once more and write the trace when `trace` is enabled.
|
|
694
|
+
export async function writeIntelliStoryTrace(percy, intelliStoryConfig, log = logger('storybook:intelliStory')) {
|
|
695
|
+
var _percy$build3;
|
|
696
|
+
const {
|
|
697
|
+
trace
|
|
698
|
+
} = intelliStoryConfig || {};
|
|
699
|
+
if (!trace) return;
|
|
700
|
+
const buildId = (_percy$build3 = percy.build) === null || _percy$build3 === void 0 ? void 0 : _percy$build3.id;
|
|
701
|
+
if (!buildId) return;
|
|
702
|
+
log.debug(`IntelliStory: fetching finalized graph data for build ${buildId} to write trace`);
|
|
703
|
+
const {
|
|
704
|
+
status,
|
|
705
|
+
data
|
|
706
|
+
} = await pollGraphStatus(percy, buildId, log);
|
|
707
|
+
if (status !== 'done') {
|
|
708
|
+
log.debug(`IntelliStory: graph status "${status ?? 'timed out'}" after finalize; skipping trace`);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
maybeWriteTrace(trace, data, log);
|
|
712
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import logger from '@percy/logger';
|
|
3
|
+
|
|
4
|
+
// snyk-nodejs-lockfile-parser is a CommonJS optionalDependency. It requires
|
|
5
|
+
// Node >=18 while the CLI supports Node >=14, so we defer the require to call
|
|
6
|
+
// time — that way importing this module never throws on older Node versions
|
|
7
|
+
// (or when the optional install was skipped for any other reason). Cached on
|
|
8
|
+
// first successful load so the require only resolves once per process.
|
|
9
|
+
// Bound to a non-`require` name on purpose: when this ESM file is transpiled to
|
|
10
|
+
// CommonJS for the packaged binary, naming it `require` collides with two Babel
|
|
11
|
+
// transforms at once — preset-env renames the local `require` to `_require`, and
|
|
12
|
+
// transform-import-meta expands `import.meta.url` into a `require('url')` call
|
|
13
|
+
// that gets renamed to `_require` too, producing `_require(...)` inside its own
|
|
14
|
+
// initializer (TypeError: _require is not a function). See PER intelliStory binary.
|
|
15
|
+
const cjsRequire = createRequire(import.meta.url);
|
|
16
|
+
let _snykModule;
|
|
17
|
+
/* istanbul ignore next: snyk-backed path — the parser requires Node >=18 while
|
|
18
|
+
CI runs the suite on Node 14, so these lines can't execute there; they're
|
|
19
|
+
exercised by the describeSnyk tests on Node >=18 */
|
|
20
|
+
function loadSnyk() {
|
|
21
|
+
if (_snykModule) return _snykModule;
|
|
22
|
+
try {
|
|
23
|
+
_snykModule = cjsRequire('snyk-nodejs-lockfile-parser');
|
|
24
|
+
return _snykModule;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const err = new Error(`snyk-nodejs-lockfile-parser is not available (requires Node >=18, or the optional install was skipped): ${e.message}`);
|
|
27
|
+
err.code = 'SNYK_LOCKFILE_PARSER_UNAVAILABLE';
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const TYPE_KEY_BY_FILENAME = {
|
|
32
|
+
'package-lock.json': 'npm',
|
|
33
|
+
'yarn.lock': 'yarn',
|
|
34
|
+
'pnpm-lock.yaml': 'pnpm'
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/* istanbul ignore next */
|
|
38
|
+
function flattenPkgTree(tree) {
|
|
39
|
+
const out = new Map();
|
|
40
|
+
const walk = node => {
|
|
41
|
+
if (!(node !== null && node !== void 0 && node.dependencies)) return;
|
|
42
|
+
for (const [name, child] of Object.entries(node.dependencies)) {
|
|
43
|
+
if (!out.has(name)) out.set(name, child.version);
|
|
44
|
+
walk(child);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(tree);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* istanbul ignore next */
|
|
52
|
+
function topLevelDeps(packageJsonContents) {
|
|
53
|
+
try {
|
|
54
|
+
const pkg = JSON.parse(packageJsonContents);
|
|
55
|
+
return {
|
|
56
|
+
...(pkg.dependencies || {}),
|
|
57
|
+
...(pkg.peerDependencies || {})
|
|
58
|
+
};
|
|
59
|
+
} catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function diffLockfileDeps({
|
|
64
|
+
packageJson,
|
|
65
|
+
oldPackageJson,
|
|
66
|
+
oldLockfile,
|
|
67
|
+
newLockfile,
|
|
68
|
+
lockfileType
|
|
69
|
+
}) {
|
|
70
|
+
const log = logger('storybook:intelliStory:lockfile');
|
|
71
|
+
const typeKey = TYPE_KEY_BY_FILENAME[lockfileType];
|
|
72
|
+
/* istanbul ignore else */
|
|
73
|
+
if (!typeKey) {
|
|
74
|
+
throw new Error(`Unsupported lockfile type: ${lockfileType}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* istanbul ignore next */
|
|
78
|
+
return resolveAffectedDeps({
|
|
79
|
+
packageJson,
|
|
80
|
+
oldPackageJson,
|
|
81
|
+
oldLockfile,
|
|
82
|
+
newLockfile,
|
|
83
|
+
typeKey,
|
|
84
|
+
log
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* istanbul ignore next */
|
|
89
|
+
async function resolveAffectedDeps({
|
|
90
|
+
packageJson,
|
|
91
|
+
oldPackageJson,
|
|
92
|
+
oldLockfile,
|
|
93
|
+
newLockfile,
|
|
94
|
+
typeKey,
|
|
95
|
+
log
|
|
96
|
+
}) {
|
|
97
|
+
const {
|
|
98
|
+
buildDepTree,
|
|
99
|
+
LockfileType
|
|
100
|
+
} = loadSnyk();
|
|
101
|
+
const type = LockfileType[typeKey];
|
|
102
|
+
let oldTree;
|
|
103
|
+
try {
|
|
104
|
+
log.debug('buildDepTree: parsing OLD lockfile...');
|
|
105
|
+
oldTree = await buildDepTree(oldPackageJson, oldLockfile, true, type);
|
|
106
|
+
log.debug('buildDepTree: OLD lockfile parsed successfully');
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log.warn(`buildDepTree: OLD lockfile failed to parse: ${e.message}`);
|
|
109
|
+
throw e;
|
|
110
|
+
}
|
|
111
|
+
let newTree;
|
|
112
|
+
try {
|
|
113
|
+
log.debug('buildDepTree: parsing NEW lockfile...');
|
|
114
|
+
newTree = await buildDepTree(packageJson, newLockfile, true, type);
|
|
115
|
+
log.debug('buildDepTree: NEW lockfile parsed successfully');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log.warn(`buildDepTree: NEW lockfile failed to parse: ${e.message}`);
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
const oldPkgs = flattenPkgTree(oldTree);
|
|
121
|
+
const newPkgs = flattenPkgTree(newTree);
|
|
122
|
+
const oldTopLevel = topLevelDeps(oldPackageJson);
|
|
123
|
+
const newTopLevel = topLevelDeps(packageJson);
|
|
124
|
+
const topLevelNames = new Set([...Object.keys(oldTopLevel), ...Object.keys(newTopLevel)]);
|
|
125
|
+
const affected = new Set();
|
|
126
|
+
for (const [name, version] of newPkgs) {
|
|
127
|
+
if (!topLevelNames.has(name)) continue;
|
|
128
|
+
if (!oldPkgs.has(name) || oldPkgs.get(name) !== version) affected.add(name);
|
|
129
|
+
}
|
|
130
|
+
for (const [name] of oldPkgs) {
|
|
131
|
+
if (!topLevelNames.has(name)) continue;
|
|
132
|
+
if (!newPkgs.has(name)) affected.add(name);
|
|
133
|
+
}
|
|
134
|
+
for (const name of topLevelNames) {
|
|
135
|
+
if (oldTopLevel[name] !== newTopLevel[name]) affected.add(name);
|
|
136
|
+
}
|
|
137
|
+
return [...affected];
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/cli-command",
|
|
3
|
-
"version": "1.32.
|
|
3
|
+
"version": "1.32.7-beta.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
".": "./dist/index.js",
|
|
28
28
|
"./flags": "./dist/flags.js",
|
|
29
29
|
"./utils": "./dist/utils.js",
|
|
30
|
+
"./intelliStory": "./dist/intelliStory.js",
|
|
30
31
|
"./test/helpers": "./test/helpers.js"
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
@@ -36,9 +37,13 @@
|
|
|
36
37
|
"test:coverage": "yarn test --coverage"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
|
-
"@percy/config": "1.32.
|
|
40
|
-
"@percy/core": "1.32.
|
|
41
|
-
"@percy/logger": "1.32.
|
|
40
|
+
"@percy/config": "1.32.7-beta.0",
|
|
41
|
+
"@percy/core": "1.32.7-beta.0",
|
|
42
|
+
"@percy/logger": "1.32.7-beta.0",
|
|
43
|
+
"glob-to-regexp": "^0.4.1"
|
|
42
44
|
},
|
|
43
|
-
"
|
|
45
|
+
"optionalDependencies": {
|
|
46
|
+
"snyk-nodejs-lockfile-parser": "2.7.1"
|
|
47
|
+
},
|
|
48
|
+
"gitHead": "f86a18de3e04d3cfa9d14ce5d186ad66c3f8e020"
|
|
44
49
|
}
|