@percy/cli-command 1.32.0-beta.6 → 1.32.0-beta.8
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/command.js +33 -11
- package/dist/graphTrace.js +157 -0
- package/dist/graphTraceTemplate.html +349 -0
- package/dist/index.js +1 -0
- package/dist/lockfileDiff.js +184 -0
- package/dist/smartsnap.js +632 -0
- package/package.json +11 -5
package/dist/command.js
CHANGED
|
@@ -30,12 +30,20 @@ let shutdownState = {
|
|
|
30
30
|
let activeContext = null;
|
|
31
31
|
const DEFAULT_DRAIN_MS = 30_000;
|
|
32
32
|
const HARD_EXIT_AFTER_FORCE_MS = 5_000;
|
|
33
|
-
// POSIX-conventional signal exit codes: 128 + signal number.
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
// POSIX-conventional signal exit codes: 128 + signal number. SIGINT and
|
|
34
|
+
// SIGTERM additionally engage drain semantics in beginShutdown; the
|
|
35
|
+
// remaining signals only need an exit-code mapping so a signal-aborted
|
|
36
|
+
// run never resolves as a false-positive success (PER-8678).
|
|
37
|
+
const EXIT_CODE_SIGINT = 130; // 128 + 2
|
|
38
|
+
const EXIT_CODE_SIGTERM = 143; // 128 + 15
|
|
36
39
|
const SIGNAL_EXIT_CODES = {
|
|
37
40
|
SIGINT: EXIT_CODE_SIGINT,
|
|
38
|
-
SIGTERM: EXIT_CODE_SIGTERM
|
|
41
|
+
SIGTERM: EXIT_CODE_SIGTERM,
|
|
42
|
+
SIGHUP: 129,
|
|
43
|
+
// 128 + 1
|
|
44
|
+
SIGUSR1: 138,
|
|
45
|
+
// 128 + 10
|
|
46
|
+
SIGUSR2: 140 // 128 + 12
|
|
39
47
|
};
|
|
40
48
|
|
|
41
49
|
// Begin or escalate drain. Idempotent on the same signal.
|
|
@@ -351,18 +359,32 @@ export function command(name, definition, callback) {
|
|
|
351
359
|
if (err.exitCode === 0) log.warn(err.message);else log.error(err);
|
|
352
360
|
}
|
|
353
361
|
|
|
354
|
-
// Signal-driven shutdown — when
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
362
|
+
// Signal-driven shutdown — when ANY process signal aborted this
|
|
363
|
+
// run, set the signal-derived exit code (130 SIGINT / 143 SIGTERM
|
|
364
|
+
// / 129 SIGHUP / 138 SIGUSR1 / 140 SIGUSR2, defaulting to 1 for
|
|
365
|
+
// any other signal) and return. Keying off `err.signal` (set on
|
|
366
|
+
// the AbortError by the per-signal handler) rather than
|
|
367
|
+
// `shutdownState.signal` is deliberate: beginShutdown only records
|
|
368
|
+
// SIGINT/SIGTERM into shutdownState for drain semantics, so the
|
|
369
|
+
// old `shutdownState.signal` gate let SIGHUP/SIGUSR1/SIGUSR2 fall
|
|
370
|
+
// through to the exitCode-0 AbortError path and exit 0 — a
|
|
371
|
+
// false-positive CI success when a run was aborted during startup
|
|
372
|
+
// before the wrapped command launched (PER-8678). We deliberately
|
|
373
|
+
// set `process.exitCode` and unwind cleanly rather than calling
|
|
358
374
|
// `process.exit()`, so the surrounding catch's finally block (and
|
|
359
375
|
// the lockfile's `process.on('exit')` handler) still run. The
|
|
360
376
|
// event loop drains naturally because the unref'd drain/hard-exit
|
|
361
377
|
// timers don't keep it alive. Tests with `exitOnError: false`
|
|
362
378
|
// preserve the legacy clean-resolution behavior because
|
|
363
|
-
// AbortError carries exitCode:0 and
|
|
364
|
-
if (
|
|
365
|
-
|
|
379
|
+
// AbortError carries exitCode:0 and this gate is skipped.
|
|
380
|
+
if (err.signal && definition.exitOnError) {
|
|
381
|
+
// Defensive non-zero fallback: every signal in the registered
|
|
382
|
+
// handler set above is mapped in SIGNAL_EXIT_CODES, so the `?? 1`
|
|
383
|
+
// branch only fires if a future signal is added to the handler
|
|
384
|
+
// list without a code — kept non-zero so the false-positive
|
|
385
|
+
// success can't reappear (PER-8678).
|
|
386
|
+
/* istanbul ignore next */
|
|
387
|
+
let signalCode = SIGNAL_EXIT_CODES[err.signal] ?? 1;
|
|
366
388
|
let percyExitWithZeroOnError = process.env.PERCY_EXIT_WITH_ZERO_ON_ERROR === 'true';
|
|
367
389
|
process.exitCode = percyExitWithZeroOnError ? 0 : signalCode;
|
|
368
390
|
return;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import url from 'url';
|
|
4
|
+
|
|
5
|
+
// Template resolution mirrors core/utils.js's secretPatterns.yml lookup:
|
|
6
|
+
// resolves relative to this file's URL so it works under src/ (dev) and
|
|
7
|
+
// dist/ (installed) without bundler help. The .html file is copied alongside
|
|
8
|
+
// by babel's copyFiles when cli-command is built.
|
|
9
|
+
const TEMPLATE_PATH = path.resolve(url.fileURLToPath(import.meta.url), '../graphTraceTemplate.html');
|
|
10
|
+
|
|
11
|
+
// Maps a (raw kind, changed) pair to the kind value the template expects:
|
|
12
|
+
// 'package' | 'component' | 'story' | 'is_relevant'. `changed: true` wins
|
|
13
|
+
// over the underlying kind so any node touched in the diff renders purple.
|
|
14
|
+
function templateKindOf(v) {
|
|
15
|
+
if (v.changed) return 'is_relevant';
|
|
16
|
+
switch (v.kind) {
|
|
17
|
+
case 'dependency':
|
|
18
|
+
return 'package';
|
|
19
|
+
case 'component':
|
|
20
|
+
return 'component';
|
|
21
|
+
case 'story':
|
|
22
|
+
return 'story';
|
|
23
|
+
default:
|
|
24
|
+
return 'component';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Sort order within a column: packages left, components middle, stories right.
|
|
29
|
+
// `is_relevant` shares rank with components so a changed node doesn't jump
|
|
30
|
+
// out of its own group — it just recolors.
|
|
31
|
+
const KIND_RANK = {
|
|
32
|
+
package: 0,
|
|
33
|
+
component: 1,
|
|
34
|
+
is_relevant: 1,
|
|
35
|
+
story: 2
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Layout algorithm (ported from the original Ruby renderer):
|
|
39
|
+
// 1. col = longest-path depth reaching the vertex (read from the
|
|
40
|
+
// transitive-closure triples the API sends), with dependencies pinned
|
|
41
|
+
// to col 0.
|
|
42
|
+
// 2. Propagate over edges so col[target] > col[source]. Bounded loop
|
|
43
|
+
// guards against degenerate inputs.
|
|
44
|
+
// 3. Stories pushed past the rightmost non-story column.
|
|
45
|
+
// 4. Within each column, sort by (kind-rank, name) and assign row.
|
|
46
|
+
function computeLayout(rawVertices, edges, transitiveClosure) {
|
|
47
|
+
const n = rawVertices.length;
|
|
48
|
+
const vertices = rawVertices.map((v, i) => ({
|
|
49
|
+
index: i,
|
|
50
|
+
name: v.file_path,
|
|
51
|
+
kind: v.kind,
|
|
52
|
+
changed: !!v.changed,
|
|
53
|
+
row: 0,
|
|
54
|
+
col: 0
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
// 1. Seed col from incoming transitive-closure lengths.
|
|
58
|
+
const incomingMax = new Array(n).fill(0);
|
|
59
|
+
for (const triple of transitiveClosure) {
|
|
60
|
+
const [u, v, val] = triple;
|
|
61
|
+
if (u === v || val <= 0) continue;
|
|
62
|
+
if (v < 0 || v >= n) continue;
|
|
63
|
+
if (val > incomingMax[v]) incomingMax[v] = val;
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < n; i++) {
|
|
66
|
+
vertices[i].col = vertices[i].kind === 'dependency' ? 0 : incomingMax[i] + 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 2. Propagate edge constraint. n+2 iterations is enough for any DAG
|
|
70
|
+
// and bounds the work on accidentally-cyclic input.
|
|
71
|
+
const iterations = n + 2;
|
|
72
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
73
|
+
let changed = false;
|
|
74
|
+
for (const [s, t] of edges) {
|
|
75
|
+
if (s < 0 || s >= n || t < 0 || t >= n) continue;
|
|
76
|
+
if (vertices[s].col < vertices[t].col) continue;
|
|
77
|
+
vertices[t].col = vertices[s].col + 1;
|
|
78
|
+
changed = true;
|
|
79
|
+
}
|
|
80
|
+
if (!changed) break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3. Stories rightmost. Two passes: max across non-stories first, then
|
|
84
|
+
// push every story past that boundary. Folding into one loop would let
|
|
85
|
+
// stories visited before the last non-story keep a stale max.
|
|
86
|
+
let furthestNonStory = 0;
|
|
87
|
+
for (const v of vertices) {
|
|
88
|
+
if (v.kind === 'story') continue;
|
|
89
|
+
if (v.col > furthestNonStory) furthestNonStory = v.col;
|
|
90
|
+
}
|
|
91
|
+
for (const v of vertices) {
|
|
92
|
+
if (v.kind !== 'story') continue;
|
|
93
|
+
if (v.col < furthestNonStory + 1) v.col = furthestNonStory + 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. Group by column, sort by (kind-rank, name), assign row.
|
|
97
|
+
const groups = new Map();
|
|
98
|
+
for (const v of vertices) {
|
|
99
|
+
let list = groups.get(v.col);
|
|
100
|
+
if (!list) groups.set(v.col, list = []);
|
|
101
|
+
list.push(v);
|
|
102
|
+
}
|
|
103
|
+
const rankOf = v => {
|
|
104
|
+
const r = KIND_RANK[templateKindOf(v)];
|
|
105
|
+
/* istanbul ignore next: templateKindOf always returns a kind present in
|
|
106
|
+
KIND_RANK, so the `=== undefined` fallback is defensive */
|
|
107
|
+
return r === undefined ? 99 : r;
|
|
108
|
+
};
|
|
109
|
+
for (const list of groups.values()) {
|
|
110
|
+
list.sort((a, b) => {
|
|
111
|
+
const ra = rankOf(a);
|
|
112
|
+
const rb = rankOf(b);
|
|
113
|
+
if (ra !== rb) return ra - rb;
|
|
114
|
+
// Byte-wise compare on name to match Ruby's String#<=> behaviour.
|
|
115
|
+
if (a.name < b.name) return -1;
|
|
116
|
+
if (a.name > b.name) return 1;
|
|
117
|
+
return 0;
|
|
118
|
+
});
|
|
119
|
+
list.forEach((v, row) => {
|
|
120
|
+
v.row = row;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 5. Final shape the template consumes: drop `changed`, fold it into kind.
|
|
125
|
+
return vertices.map(v => ({
|
|
126
|
+
index: v.index,
|
|
127
|
+
name: v.name,
|
|
128
|
+
row: v.row,
|
|
129
|
+
col: v.col,
|
|
130
|
+
kind: templateKindOf(v)
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Escapes characters that have meaning inside a <script> block so user-derived
|
|
135
|
+
// strings (e.g. a vertex file_path) can't break out of the surrounding tag.
|
|
136
|
+
// `</` covers `</script>`; `<!--`/`-->` cover HTML comment confusion; U+2028
|
|
137
|
+
// and U+2029 are valid JSON but illegal in JS string literals pre-ES2019 and
|
|
138
|
+
// have historically been XSS sinks.
|
|
139
|
+
const LS = String.fromCharCode(0x2028);
|
|
140
|
+
const PS = String.fromCharCode(0x2029);
|
|
141
|
+
function safeJson(obj) {
|
|
142
|
+
return JSON.stringify(obj).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--').replace(/--(!?)>/g, '--$1\\>').split(LS).join('\\u2028').split(PS).join('\\u2029');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Populates the trace template with the three JSON payloads the page needs.
|
|
146
|
+
// Input shape matches the API's graph data: `vertices` carries `kind`,
|
|
147
|
+
// `file_path`, `changed`; `edges` and `transitive_closure_matrix_sparse`
|
|
148
|
+
// are arrays of integer tuples.
|
|
149
|
+
export function renderGraphTraceHtml({
|
|
150
|
+
vertices,
|
|
151
|
+
edges,
|
|
152
|
+
transitiveClosureMatrixSparse
|
|
153
|
+
}) {
|
|
154
|
+
const laidOutVertices = computeLayout(vertices || [], edges || [], transitiveClosureMatrixSparse || []);
|
|
155
|
+
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
156
|
+
return template.replace('__VERTICES_JSON__', safeJson(laidOutVertices)).replace('__EDGES_JSON__', safeJson(edges || [])).replace('__TRANSITIVE_CLOSURE_JSON__', safeJson(transitiveClosureMatrixSparse || []));
|
|
157
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Smartsnap 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
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Payload populated by the CLI before this template is written to disk.
|
|
120
|
+
// Tokens are replaced by graphTrace.js via plain string substitution.
|
|
121
|
+
// vertices: [{ index, name, row, col, kind }, ...]
|
|
122
|
+
// edges: [[srcIndex, tgtIndex], ...]
|
|
123
|
+
// transitive_closure_matrix_sparse: [[srcIndex, tgtIndex, length], ...]
|
|
124
|
+
// `kind` is one of: 'package' | 'component' | 'story' | 'is_relevant'.
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
const vertices = __VERTICES_JSON__;
|
|
127
|
+
const edges = __EDGES_JSON__;
|
|
128
|
+
const transitive_closure_matrix_sparse = __TRANSITIVE_CLOSURE_JSON__;
|
|
129
|
+
const n = vertices.length;
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Width measurement: each node grows to fit its file path. We need the
|
|
133
|
+
// rendered width (DOM-dependent) to lay out columns without overlap.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
function measureWidth(text) {
|
|
136
|
+
const el = document.createElement('div');
|
|
137
|
+
el.className = 'measure-node';
|
|
138
|
+
el.textContent = text;
|
|
139
|
+
document.body.appendChild(el);
|
|
140
|
+
const w = el.offsetWidth;
|
|
141
|
+
el.remove();
|
|
142
|
+
return w;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Drawflow adds port circles on each side (~25px) + border; pad generously.
|
|
146
|
+
const PORT_PAD = 60;
|
|
147
|
+
const nodeWidth = vertices.map(v => measureWidth(v.name) + PORT_PAD);
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Column X positions (cumulative by max width per column).
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
const PAD_X = 60, PAD_Y = 60, COL_GAP = 140, ROW_HEIGHT = 70;
|
|
153
|
+
|
|
154
|
+
const colMaxWidth = {};
|
|
155
|
+
for (let i = 0; i < n; i++) {
|
|
156
|
+
const c = vertices[i].col;
|
|
157
|
+
if (!colMaxWidth[c] || nodeWidth[i] > colMaxWidth[c]) colMaxWidth[c] = nodeWidth[i];
|
|
158
|
+
}
|
|
159
|
+
const sortedCols = Object.keys(colMaxWidth).map(Number).sort((a, b) => a - b);
|
|
160
|
+
const colX = {};
|
|
161
|
+
let cursor = PAD_X;
|
|
162
|
+
for (const c of sortedCols) {
|
|
163
|
+
colX[c] = cursor;
|
|
164
|
+
cursor += colMaxWidth[c] + COL_GAP;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Boot Drawflow.
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
const container = document.getElementById('drawflow');
|
|
171
|
+
const editor = new Drawflow(container);
|
|
172
|
+
editor.reroute = false;
|
|
173
|
+
editor.start();
|
|
174
|
+
|
|
175
|
+
function escapeHtml(s) {
|
|
176
|
+
return String(s)
|
|
177
|
+
.replace(/&/g, '&')
|
|
178
|
+
.replace(/</g, '<')
|
|
179
|
+
.replace(/>/g, '>')
|
|
180
|
+
.replace(/"/g, '"')
|
|
181
|
+
.replace(/'/g, ''');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// CSS classes per kind. `is_relevant` becomes `is-relevant` so it matches
|
|
185
|
+
// the stylesheet rule.
|
|
186
|
+
function cssClassFor(kind) {
|
|
187
|
+
if (kind === 'is_relevant') return 'is-relevant';
|
|
188
|
+
return kind;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Add nodes. row/col come from the server; pixel positions are computed
|
|
193
|
+
// here using the measured widths so columns never overlap.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
const nodeIds = new Array(n);
|
|
196
|
+
for (let i = 0; i < n; i++) {
|
|
197
|
+
const v = vertices[i];
|
|
198
|
+
const id = editor.addNode(
|
|
199
|
+
'node',
|
|
200
|
+
1, 1,
|
|
201
|
+
colX[v.col],
|
|
202
|
+
PAD_Y + v.row * ROW_HEIGHT,
|
|
203
|
+
cssClassFor(v.kind),
|
|
204
|
+
{ index: v.index, kind: v.kind, name: v.name },
|
|
205
|
+
escapeHtml(v.name),
|
|
206
|
+
);
|
|
207
|
+
nodeIds[i] = id;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Connections.
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
for (const e of edges) {
|
|
214
|
+
editor.addConnection(nodeIds[e[0]], nodeIds[e[1]], 'output_1', 'input_1');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
editor.editor_mode = 'fixed';
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Arrowheads on connection paths.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
223
|
+
function addArrowhead(svg) {
|
|
224
|
+
if (svg.querySelector('marker')) return;
|
|
225
|
+
let defs = svg.querySelector('defs');
|
|
226
|
+
if (!defs) {
|
|
227
|
+
defs = document.createElementNS(SVG_NS, 'defs');
|
|
228
|
+
svg.insertBefore(defs, svg.firstChild);
|
|
229
|
+
}
|
|
230
|
+
const marker = document.createElementNS(SVG_NS, 'marker');
|
|
231
|
+
marker.setAttribute('id', 'arrowhead');
|
|
232
|
+
marker.setAttribute('viewBox', '0 0 10 10');
|
|
233
|
+
marker.setAttribute('refX', '9');
|
|
234
|
+
marker.setAttribute('refY', '5');
|
|
235
|
+
marker.setAttribute('markerWidth', '5');
|
|
236
|
+
marker.setAttribute('markerHeight', '5');
|
|
237
|
+
marker.setAttribute('orient', 'auto');
|
|
238
|
+
const tip = document.createElementNS(SVG_NS, 'path');
|
|
239
|
+
tip.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z');
|
|
240
|
+
tip.setAttribute('fill', '#4ea9ff');
|
|
241
|
+
marker.appendChild(tip);
|
|
242
|
+
defs.appendChild(marker);
|
|
243
|
+
const path = svg.querySelector('path.main-path') || svg.querySelector('path');
|
|
244
|
+
if (path) path.setAttribute('marker-end', 'url(#arrowhead)');
|
|
245
|
+
}
|
|
246
|
+
container.querySelectorAll('svg.connection').forEach(addArrowhead);
|
|
247
|
+
|
|
248
|
+
new MutationObserver((muts) => {
|
|
249
|
+
for (const m of muts) {
|
|
250
|
+
for (const node of m.addedNodes) {
|
|
251
|
+
if (node.nodeType !== 1) continue;
|
|
252
|
+
if (node.matches && node.matches('svg.connection')) addArrowhead(node);
|
|
253
|
+
if (node.querySelectorAll) {
|
|
254
|
+
node.querySelectorAll('svg.connection').forEach(addArrowhead);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}).observe(container, { subtree: true, childList: true });
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Click-to-select with bidirectional reachability highlight.
|
|
262
|
+
//
|
|
263
|
+
// Reachability comes straight from transitive_closure_matrix_sparse: for a click
|
|
264
|
+
// on vertex `start`, anything appearing as the target of a triple
|
|
265
|
+
// (start, j, _) is reachable from `start`, and anything appearing as the
|
|
266
|
+
// source of a triple (i, start, _) can reach `start`. No adjacency list —
|
|
267
|
+
// we just iterate the triples each time. With small click counts and a
|
|
268
|
+
// sparse matrix this is fast enough and avoids carrying redundant graph
|
|
269
|
+
// state in the page.
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
const idxByDfId = new Map();
|
|
272
|
+
for (let i = 0; i < n; i++) {
|
|
273
|
+
if (nodeIds[i] !== undefined) idxByDfId.set(nodeIds[i], i);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function connectedTo(start) {
|
|
277
|
+
const seen = new Set([start]);
|
|
278
|
+
for (const transitive_edge of transitive_closure_matrix_sparse) {
|
|
279
|
+
if (transitive_edge[0] === start) seen.add(transitive_edge[1]);
|
|
280
|
+
else if (transitive_edge[1] === start) seen.add(transitive_edge[0]);
|
|
281
|
+
}
|
|
282
|
+
return seen;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseEndpointIds(svg) {
|
|
286
|
+
let outId = null, inId = null;
|
|
287
|
+
for (const cls of svg.classList) {
|
|
288
|
+
if (cls.startsWith('node_out_node-')) outId = parseInt(cls.slice('node_out_node-'.length), 10);
|
|
289
|
+
else if (cls.startsWith('node_in_node-')) inId = parseInt(cls.slice('node_in_node-'.length), 10);
|
|
290
|
+
}
|
|
291
|
+
return { outId, inId };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let currentSelected = null;
|
|
295
|
+
|
|
296
|
+
function clearSelection() {
|
|
297
|
+
container.querySelectorAll('.drawflow-node.is-dim, .drawflow-node.is-selected')
|
|
298
|
+
.forEach(el => el.classList.remove('is-dim', 'is-selected'));
|
|
299
|
+
container.querySelectorAll('svg.connection.is-dim')
|
|
300
|
+
.forEach(el => el.classList.remove('is-dim'));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function applySelection(startIdx) {
|
|
304
|
+
const connected = connectedTo(startIdx);
|
|
305
|
+
|
|
306
|
+
for (let i = 0; i < n; i++) {
|
|
307
|
+
const el = document.getElementById('node-' + nodeIds[i]);
|
|
308
|
+
if (!el) continue;
|
|
309
|
+
el.classList.toggle('is-dim', !connected.has(i));
|
|
310
|
+
el.classList.toggle('is-selected', i === startIdx);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
container.querySelectorAll('svg.connection').forEach((svg) => {
|
|
314
|
+
const { outId, inId } = parseEndpointIds(svg);
|
|
315
|
+
const s = idxByDfId.get(outId);
|
|
316
|
+
const t = idxByDfId.get(inId);
|
|
317
|
+
const keep = (s !== undefined && t !== undefined
|
|
318
|
+
&& connected.has(s) && connected.has(t));
|
|
319
|
+
svg.classList.toggle('is-dim', !keep);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
container.addEventListener('click', (evt) => {
|
|
324
|
+
const nodeEl = evt.target.closest('.drawflow-node');
|
|
325
|
+
if (!nodeEl) return;
|
|
326
|
+
const m = nodeEl.id.match(/^node-(\d+)$/);
|
|
327
|
+
if (!m) return;
|
|
328
|
+
const dfId = parseInt(m[1], 10);
|
|
329
|
+
const idx = idxByDfId.get(dfId);
|
|
330
|
+
if (idx === undefined) return;
|
|
331
|
+
|
|
332
|
+
if (currentSelected === idx) {
|
|
333
|
+
currentSelected = null;
|
|
334
|
+
clearSelection();
|
|
335
|
+
} else {
|
|
336
|
+
currentSelected = idx;
|
|
337
|
+
applySelection(idx);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Toolbar.
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
document.getElementById('zoom-in').onclick = () => editor.zoom_in();
|
|
345
|
+
document.getElementById('zoom-out').onclick = () => editor.zoom_out();
|
|
346
|
+
document.getElementById('zoom-reset').onclick = () => { editor.zoom_reset(); currentSelected = null; clearSelection(); };
|
|
347
|
+
</script>
|
|
348
|
+
</body>
|
|
349
|
+
</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 { applySmartSnap, SmartSnapBailError } from './smartsnap.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';
|