@juxtapose/core 0.2.0 → 0.5.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/agent/handoff.d.ts +8 -1
- package/dist/agent/handoff.js +75 -7
- package/dist/agent/handoff.js.map +1 -1
- package/dist/analysis/affected.d.ts +58 -0
- package/dist/analysis/affected.js +134 -22
- package/dist/analysis/affected.js.map +1 -1
- package/dist/analysis/cause.d.ts +32 -0
- package/dist/analysis/cause.js +187 -0
- package/dist/analysis/cause.js.map +1 -0
- package/dist/baseline/approve.d.ts +24 -0
- package/dist/baseline/approve.js +54 -0
- package/dist/baseline/approve.js.map +1 -0
- package/dist/baseline/git-store.d.ts +4 -0
- package/dist/baseline/git-store.js +22 -0
- package/dist/baseline/git-store.js.map +1 -1
- package/dist/browser/acquire.d.ts +11 -0
- package/dist/browser/acquire.js +15 -0
- package/dist/browser/acquire.js.map +1 -0
- package/dist/browser/browser.d.ts +53 -0
- package/dist/browser/browser.js +122 -0
- package/dist/browser/browser.js.map +1 -0
- package/dist/browser/cdp.d.ts +81 -0
- package/dist/browser/cdp.js +230 -0
- package/dist/browser/cdp.js.map +1 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.js +10 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/install.d.ts +33 -0
- package/dist/browser/install.js +167 -0
- package/dist/browser/install.js.map +1 -0
- package/dist/browser/launch.d.ts +37 -0
- package/dist/browser/launch.js +265 -0
- package/dist/browser/launch.js.map +1 -0
- package/dist/browser/page.d.ts +125 -0
- package/dist/browser/page.js +564 -0
- package/dist/browser/page.js.map +1 -0
- package/dist/browser/pins.d.ts +12 -0
- package/dist/browser/pins.js +57 -0
- package/dist/browser/pins.js.map +1 -0
- package/dist/browser/resolve.d.ts +25 -0
- package/dist/browser/resolve.js +115 -0
- package/dist/browser/resolve.js.map +1 -0
- package/dist/browser/selectors.d.ts +10 -0
- package/dist/browser/selectors.js +60 -0
- package/dist/browser/selectors.js.map +1 -0
- package/dist/browser/transport.d.ts +39 -0
- package/dist/browser/transport.js +99 -0
- package/dist/browser/transport.js.map +1 -0
- package/dist/browser/zip.d.ts +27 -0
- package/dist/browser/zip.js +241 -0
- package/dist/browser/zip.js.map +1 -0
- package/dist/capture/capture.d.ts +7 -1
- package/dist/capture/capture.js +27 -3
- package/dist/capture/capture.js.map +1 -1
- package/dist/capture/env.d.ts +8 -0
- package/dist/capture/env.js +38 -0
- package/dist/capture/env.js.map +1 -0
- package/dist/capture/launch.d.ts +19 -1
- package/dist/capture/launch.js +91 -1
- package/dist/capture/launch.js.map +1 -1
- package/dist/capture/pool.d.ts +1 -1
- package/dist/capture/pool.js +41 -8
- package/dist/capture/pool.js.map +1 -1
- package/dist/capture/settle.d.ts +1 -1
- package/dist/capture/settle.js +2 -0
- package/dist/capture/settle.js.map +1 -1
- package/dist/capture/snap.d.ts +43 -0
- package/dist/capture/snap.js +103 -0
- package/dist/capture/snap.js.map +1 -0
- package/dist/capture/structure.d.ts +77 -0
- package/dist/capture/structure.js +498 -0
- package/dist/capture/structure.js.map +1 -0
- package/dist/diff/compare.d.ts +19 -0
- package/dist/diff/compare.js +19 -0
- package/dist/diff/compare.js.map +1 -0
- package/dist/diff/crops.d.ts +26 -0
- package/dist/diff/crops.js +92 -0
- package/dist/diff/crops.js.map +1 -0
- package/dist/diff/structural/classify.d.ts +29 -0
- package/dist/diff/structural/classify.js +113 -0
- package/dist/diff/structural/classify.js.map +1 -0
- package/dist/diff/structural/coverage.d.ts +11 -0
- package/dist/diff/structural/coverage.js +37 -0
- package/dist/diff/structural/coverage.js.map +1 -0
- package/dist/diff/structural/deltas.d.ts +29 -0
- package/dist/diff/structural/deltas.js +218 -0
- package/dist/diff/structural/deltas.js.map +1 -0
- package/dist/diff/structural/disambiguate.d.ts +3 -0
- package/dist/diff/structural/disambiguate.js +89 -0
- package/dist/diff/structural/disambiguate.js.map +1 -0
- package/dist/diff/structural/label.d.ts +15 -0
- package/dist/diff/structural/label.js +64 -0
- package/dist/diff/structural/label.js.map +1 -0
- package/dist/diff/structural/match.d.ts +13 -0
- package/dist/diff/structural/match.js +135 -0
- package/dist/diff/structural/match.js.map +1 -0
- package/dist/diff/structural/shift.d.ts +2 -0
- package/dist/diff/structural/shift.js +144 -0
- package/dist/diff/structural/shift.js.map +1 -0
- package/dist/diff/structural/summary.d.ts +31 -0
- package/dist/diff/structural/summary.js +420 -0
- package/dist/diff/structural/summary.js.map +1 -0
- package/dist/diff/structural.d.ts +40 -0
- package/dist/diff/structural.js +219 -0
- package/dist/diff/structural.js.map +1 -0
- package/dist/index.d.ts +34 -12
- package/dist/index.js +55 -11
- package/dist/index.js.map +1 -1
- package/dist/platform.d.ts +1 -1
- package/dist/platforms/urls.d.ts +3 -0
- package/dist/platforms/urls.js +89 -0
- package/dist/platforms/urls.js.map +1 -0
- package/dist/report/comment.d.ts +2 -1
- package/dist/report/comment.js +305 -57
- package/dist/report/comment.js.map +1 -1
- package/dist/report/format.d.ts +20 -0
- package/dist/report/format.js +32 -0
- package/dist/report/format.js.map +1 -0
- package/dist/report/github.d.ts +12 -0
- package/dist/report/github.js +21 -0
- package/dist/report/github.js.map +1 -1
- package/dist/report/html.d.ts +12 -0
- package/dist/report/html.js +202 -0
- package/dist/report/html.js.map +1 -0
- package/dist/run/groups.d.ts +2 -0
- package/dist/run/groups.js +45 -0
- package/dist/run/groups.js.map +1 -0
- package/dist/run/phases.d.ts +19 -0
- package/dist/run/phases.js +50 -0
- package/dist/run/phases.js.map +1 -0
- package/dist/run/run.d.ts +10 -2
- package/dist/run/run.js +97 -9
- package/dist/run/run.js.map +1 -1
- package/dist/shell-quote.d.ts +2 -0
- package/dist/shell-quote.js +10 -0
- package/dist/shell-quote.js.map +1 -0
- package/dist/types.d.ts +234 -3
- package/dist/url-platform.d.ts +2 -2
- package/dist/url-platform.js +2 -2
- package/dist/url-platform.js.map +1 -1
- package/package.json +14 -10
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// A single self-contained report.html.
|
|
2
|
+
//
|
|
3
|
+
// The constraint that shapes everything here: it must work opened from
|
|
4
|
+
// `file://`, because that is how someone downloads a CI artifact and looks at
|
|
5
|
+
// it. No external stylesheet, no script src, no sibling image directory —
|
|
6
|
+
// images inline as data URIs and the whole thing is one file you can email.
|
|
7
|
+
//
|
|
8
|
+
// This complements rather than duplicates the addon panel. The panel is for
|
|
9
|
+
// local review with a dev server running; this is for a reviewer looking at a
|
|
10
|
+
// pull request who has checked nothing out.
|
|
11
|
+
import { describeMagnitude } from './format.js';
|
|
12
|
+
export function buildHtmlReport({ report, images }) {
|
|
13
|
+
const needsReview = report.results.filter((r) => r.status === 'fail' || r.status === 'new' || r.status === 'stale');
|
|
14
|
+
const s = report.summary;
|
|
15
|
+
return `<!doctype html>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<title>juxtapose — ${escapeHtml(report.branch)} @ ${escapeHtml(report.commitSha.slice(0, 7))}</title>
|
|
19
|
+
<style>${STYLE}</style>
|
|
20
|
+
|
|
21
|
+
<header>
|
|
22
|
+
<h1>${needsReview.length === 0 ? 'Visual tests passed' : 'Visual changes need review'}</h1>
|
|
23
|
+
<dl>
|
|
24
|
+
${stat('pass', s.pass)}
|
|
25
|
+
${s.fail ? stat('changed', s.fail, 'bad') : ''}
|
|
26
|
+
${s.new ? stat('new', s.new, 'warn') : ''}
|
|
27
|
+
${s.stale ? stat('stale', s.stale, 'warn') : ''}
|
|
28
|
+
${s.skip ? stat('skipped', s.skip) : ''}
|
|
29
|
+
</dl>
|
|
30
|
+
<p class="meta">
|
|
31
|
+
${escapeHtml(report.branch)} ·
|
|
32
|
+
<code>${escapeHtml(report.commitSha.slice(0, 7))}</code> ·
|
|
33
|
+
${report.capturedCount}/${report.totalCount} captured ·
|
|
34
|
+
${(report.durationMs / 1000).toFixed(1)}s
|
|
35
|
+
</p>
|
|
36
|
+
</header>
|
|
37
|
+
|
|
38
|
+
${needsReview.length === 0
|
|
39
|
+
? `<p class="empty">Every captured story matches its approved baseline.</p>`
|
|
40
|
+
: needsReview.map((r) => storySection(r, images.get(r.storyId))).join('\n')}
|
|
41
|
+
|
|
42
|
+
<script>${SCRIPT}</script>
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
function stat(label, value, tone = '') {
|
|
46
|
+
return `<div class="${tone}"><dt>${label}</dt><dd>${value}</dd></div>`;
|
|
47
|
+
}
|
|
48
|
+
function storySection(result, imgs) {
|
|
49
|
+
const baseline = dataUri(imgs?.baseline);
|
|
50
|
+
const current = dataUri(imgs?.current);
|
|
51
|
+
const diff = dataUri(imgs?.diff);
|
|
52
|
+
// A new story has no baseline, so there is nothing to compare — show the
|
|
53
|
+
// capture alone rather than an empty half. Same reasoning as the panel:
|
|
54
|
+
// controls that compare nothing read as broken.
|
|
55
|
+
const comparable = Boolean(baseline && current);
|
|
56
|
+
return `
|
|
57
|
+
<section>
|
|
58
|
+
<h2>
|
|
59
|
+
<span class="badge ${result.status}">${statusLabel(result.status)}</span>
|
|
60
|
+
${escapeHtml(result.storyId)}
|
|
61
|
+
${result.status === 'fail' ? `<span class="pct">${escapeHtml(describeMagnitude(result))}</span>` : ''}
|
|
62
|
+
</h2>
|
|
63
|
+
${result.error ? `<p class="error">${escapeHtml(result.error)}</p>` : ''}
|
|
64
|
+
${comparable
|
|
65
|
+
? `
|
|
66
|
+
<div class="tabs">
|
|
67
|
+
<button data-mode="sxs" class="on">Side by side</button>
|
|
68
|
+
<button data-mode="slider">Slider</button>
|
|
69
|
+
${diff ? `<button data-mode="diff">Diff</button>` : ''}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="views">
|
|
73
|
+
<div class="view sxs on">
|
|
74
|
+
<figure><figcaption>Baseline</figcaption><img src="${baseline}" alt="baseline"></figure>
|
|
75
|
+
<figure><figcaption>Current</figcaption><img src="${current}" alt="current"></figure>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div class="view slider">
|
|
79
|
+
<div class="wipe" style="--pos:50%">
|
|
80
|
+
<img src="${baseline}" alt="baseline">
|
|
81
|
+
<img class="over" src="${current}" alt="current">
|
|
82
|
+
<div class="handle"></div>
|
|
83
|
+
</div>
|
|
84
|
+
<input type="range" min="0" max="100" value="50" aria-label="Wipe between baseline and current">
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
${diff ? `<div class="view diff"><img src="${diff}" alt="diff overlay"></div>` : ''}
|
|
88
|
+
</div>`
|
|
89
|
+
: current
|
|
90
|
+
? `<div class="single"><img src="${current}" alt="capture"></div>`
|
|
91
|
+
: `<p class="error">No capture available for this story.</p>`}
|
|
92
|
+
</section>`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Inline an image.
|
|
96
|
+
*
|
|
97
|
+
* Data URIs are what make the file portable — a sibling images/ directory
|
|
98
|
+
* breaks the moment someone moves or emails the html. The cost is roughly 33%
|
|
99
|
+
* inflation from base64, acceptable for a CI artifact.
|
|
100
|
+
*/
|
|
101
|
+
function dataUri(bytes) {
|
|
102
|
+
return bytes ? `data:image/webp;base64,${bytes.toString('base64')}` : null;
|
|
103
|
+
}
|
|
104
|
+
function statusLabel(status) {
|
|
105
|
+
switch (status) {
|
|
106
|
+
case 'fail':
|
|
107
|
+
return 'changed';
|
|
108
|
+
case 'new':
|
|
109
|
+
return 'no baseline';
|
|
110
|
+
case 'stale':
|
|
111
|
+
return 'stale';
|
|
112
|
+
default:
|
|
113
|
+
return status;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Escape anything interpolated into markup. Story ids come from the platform
|
|
117
|
+
* and are not ours to trust. */
|
|
118
|
+
function escapeHtml(value) {
|
|
119
|
+
return value
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/"/g, '"');
|
|
124
|
+
}
|
|
125
|
+
const STYLE = `
|
|
126
|
+
:root {
|
|
127
|
+
--fg: #0f172a; --muted: #64748b; --line: #e2e8f0; --bg: #fff;
|
|
128
|
+
--bad: #dc2626; --warn: #d97706;
|
|
129
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
130
|
+
}
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
:root { --fg: #e2e8f0; --muted: #94a3b8; --line: #334155; --bg: #0f172a; }
|
|
133
|
+
}
|
|
134
|
+
* { box-sizing: border-box; }
|
|
135
|
+
body { margin: 0 auto; padding: 24px; max-width: 1200px;
|
|
136
|
+
color: var(--fg); background: var(--bg); }
|
|
137
|
+
h1 { font-size: 20px; margin: 0 0 12px; }
|
|
138
|
+
dl { display: flex; gap: 20px; margin: 0 0 8px; padding: 0; }
|
|
139
|
+
dl div { display: flex; gap: 6px; align-items: baseline; }
|
|
140
|
+
dt { color: var(--muted); font-size: 13px; }
|
|
141
|
+
dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
142
|
+
dl .bad dd { color: var(--bad); } dl .warn dd { color: var(--warn); }
|
|
143
|
+
.meta { color: var(--muted); font-size: 13px; margin: 0 0 28px; }
|
|
144
|
+
.empty { color: var(--muted); }
|
|
145
|
+
section { border-top: 1px solid var(--line); padding: 20px 0; }
|
|
146
|
+
h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center;
|
|
147
|
+
gap: 10px; margin: 0 0 12px; }
|
|
148
|
+
.badge { font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
|
149
|
+
padding: 2px 8px; border-radius: 999px; color: #fff; }
|
|
150
|
+
.badge.fail { background: var(--bad); }
|
|
151
|
+
.badge.new, .badge.stale { background: var(--warn); }
|
|
152
|
+
.pct { color: var(--muted); font-weight: 400; font-size: 13px; }
|
|
153
|
+
.error { color: var(--bad); font-size: 13px; margin: 0 0 12px; }
|
|
154
|
+
.tabs { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
155
|
+
.tabs button { font: inherit; font-size: 12px; padding: 4px 10px; cursor: pointer;
|
|
156
|
+
border: 1px solid var(--line); background: transparent;
|
|
157
|
+
color: var(--muted); border-radius: 4px; }
|
|
158
|
+
.tabs button.on { background: var(--fg); color: var(--bg); border-color: var(--fg); }
|
|
159
|
+
.view { display: none; }
|
|
160
|
+
.view.on { display: block; }
|
|
161
|
+
.view.sxs.on { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
162
|
+
figure { margin: 0; }
|
|
163
|
+
figcaption { font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
|
|
164
|
+
color: var(--muted); margin-bottom: 6px; }
|
|
165
|
+
img { max-width: 100%; height: auto; display: block; border: 1px solid var(--line); }
|
|
166
|
+
.single img { max-width: 640px; }
|
|
167
|
+
.wipe { position: relative; display: inline-block; max-width: 100%; }
|
|
168
|
+
.wipe .over { position: absolute; inset: 0; clip-path: inset(0 0 0 var(--pos)); }
|
|
169
|
+
.wipe .handle { position: absolute; top: 0; bottom: 0; left: var(--pos);
|
|
170
|
+
width: 2px; background: var(--bad); pointer-events: none; }
|
|
171
|
+
input[type=range] { width: 100%; margin-top: 8px; }
|
|
172
|
+
`;
|
|
173
|
+
/**
|
|
174
|
+
* Tab switching and the wipe slider.
|
|
175
|
+
*
|
|
176
|
+
* Inline and dependency-free on purpose: a `file://` page cannot load anything
|
|
177
|
+
* external, and a report needing a build step to view defeats the point of
|
|
178
|
+
* being an artifact.
|
|
179
|
+
*/
|
|
180
|
+
const SCRIPT = `
|
|
181
|
+
document.querySelectorAll('section').forEach(function (section) {
|
|
182
|
+
section.querySelectorAll('.tabs button').forEach(function (btn) {
|
|
183
|
+
btn.addEventListener('click', function () {
|
|
184
|
+
section.querySelectorAll('.tabs button').forEach(function (b) {
|
|
185
|
+
b.classList.toggle('on', b === btn);
|
|
186
|
+
});
|
|
187
|
+
section.querySelectorAll('.view').forEach(function (v) {
|
|
188
|
+
v.classList.toggle('on', v.classList.contains(btn.dataset.mode));
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
var range = section.querySelector('input[type=range]');
|
|
194
|
+
var wipe = section.querySelector('.wipe');
|
|
195
|
+
if (range && wipe) {
|
|
196
|
+
range.addEventListener('input', function () {
|
|
197
|
+
wipe.style.setProperty('--pos', range.value + '%');
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
`;
|
|
202
|
+
//# sourceMappingURL=html.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.js","sourceRoot":"","sources":["../../src/report/html.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Which failing stories changed the same way. The verdict's signature
|
|
2
|
+
// already abstracts the element to role|tag and keeps the values, so "every
|
|
3
|
+
// button background #2563eb→#1d4ed8" hashes alike on every page that made
|
|
4
|
+
// that change; grouping is a bucket sort on it. Cold path: runs once per
|
|
5
|
+
// report, after every story is judged.
|
|
6
|
+
import { compareCodeUnits } from '../diff/structural/deltas.js';
|
|
7
|
+
export function computeGroups(results) {
|
|
8
|
+
const bySignature = new Map();
|
|
9
|
+
for (const r of results) {
|
|
10
|
+
// A verdict with no deltas has a signature that depends on the
|
|
11
|
+
// classification alone: unrelated identical, drifting or resized
|
|
12
|
+
// stories would "share" it, and there is no change to state.
|
|
13
|
+
if (r.status !== 'fail' || !r.structural || r.structural.deltaCount === 0)
|
|
14
|
+
continue;
|
|
15
|
+
const { signature, change, summary } = r.structural;
|
|
16
|
+
const text = change ?? summary;
|
|
17
|
+
const g = bySignature.get(signature);
|
|
18
|
+
if (!g)
|
|
19
|
+
bySignature.set(signature, { ids: [r.storyId], first: r.storyId, change: text });
|
|
20
|
+
else {
|
|
21
|
+
g.ids.push(r.storyId);
|
|
22
|
+
// The wording of the first story in code-unit order, whatever the
|
|
23
|
+
// input order: two verdicts of one signature can word it differently.
|
|
24
|
+
if (compareCodeUnits(r.storyId, g.first) < 0) {
|
|
25
|
+
g.first = r.storyId;
|
|
26
|
+
g.change = text;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Collected through a Map, emitted through a sort: insertion order never
|
|
31
|
+
// reaches the report.
|
|
32
|
+
const groups = [];
|
|
33
|
+
for (const [signature, g] of bySignature) {
|
|
34
|
+
if (g.ids.length < 2)
|
|
35
|
+
continue;
|
|
36
|
+
const storyIds = [...g.ids].sort(compareCodeUnits);
|
|
37
|
+
groups.push({
|
|
38
|
+
signature,
|
|
39
|
+
summary: `${g.change} — ${storyIds.length} stories`,
|
|
40
|
+
storyIds,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return groups.sort((x, y) => y.storyIds.length - x.storyIds.length || compareCodeUnits(x.signature, y.signature));
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=groups.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"groups.js","sourceRoot":"","sources":["../../src/run/groups.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,4EAA4E;AAC5E,0EAA0E;AAC1E,yEAAyE;AACzE,uCAAuC;AAEvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAGhE,MAAM,UAAU,aAAa,CAAC,OAAqB;IACjD,MAAM,WAAW,GAAG,IAAI,GAAG,EAA4D,CAAC;IACxF,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,+DAA+D;QAC/D,iEAAiE;QACjE,6DAA6D;QAC7D,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,UAAU,IAAI,CAAC,CAAC,UAAU,CAAC,UAAU,KAAK,CAAC;YAAE,SAAS;QACpF,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC,CAAC,UAAU,CAAC;QACpD,MAAM,IAAI,GAAG,MAAM,IAAI,OAAO,CAAC;QAC/B,MAAM,CAAC,GAAG,WAAW,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;QACrC,IAAI,CAAC,CAAC;YACJ,WAAW,CAAC,GAAG,CAAC,SAAS,EAAE,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;aAC9E,CAAC;YACJ,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;YACtB,kEAAkE;YAClE,sEAAsE;YACtE,IAAI,gBAAgB,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC7C,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,OAAO,CAAC;gBACpB,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC;YAClB,CAAC;QACH,CAAC;IACH,CAAC;IACD,yEAAyE;IACzE,sBAAsB;IACtB,MAAM,MAAM,GAAe,EAAE,CAAC;IAC9B,KAAK,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,IAAI,WAAW,EAAE,CAAC;QACzC,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC;YAAE,SAAS;QAC/B,MAAM,QAAQ,GAAG,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;QACnD,MAAM,CAAC,IAAI,CAAC;YACV,SAAS;YACT,OAAO,EAAE,GAAG,CAAC,CAAC,MAAM,MAAM,QAAQ,CAAC,MAAM,UAAU;YACnD,QAAQ;SACT,CAAC,CAAC;IACL,CAAC;IACD,OAAO,MAAM,CAAC,IAAI,CAChB,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CACP,CAAC,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,QAAQ,CAAC,MAAM,IAAI,gBAAgB,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,CAAC,CACtF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface Phases {
|
|
2
|
+
/** Time a promise-returning step, recording it under `name`. */
|
|
3
|
+
time<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
4
|
+
/** Add to a phase directly, for work not shaped like a single call. */
|
|
5
|
+
add(name: string, ms: number): void;
|
|
6
|
+
/** Count of things a phase handled, e.g. how many diffs actually ran. */
|
|
7
|
+
count(name: string, n?: number): void;
|
|
8
|
+
readonly timings: Record<string, number>;
|
|
9
|
+
readonly counts: Record<string, number>;
|
|
10
|
+
}
|
|
11
|
+
export declare function phases(): Phases;
|
|
12
|
+
/**
|
|
13
|
+
* `enumerate 12ms | capture 48.2s | diff(7) 810ms`
|
|
14
|
+
*
|
|
15
|
+
* Phases are printed in insertion order, which is execution order, so the line
|
|
16
|
+
* reads as the run happened. Sub-millisecond phases are dropped: they are noise
|
|
17
|
+
* next to a capture measured in seconds.
|
|
18
|
+
*/
|
|
19
|
+
export declare function formatPhases(p: Pick<Phases, 'timings' | 'counts'>): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Wall-time per stage of a run.
|
|
2
|
+
//
|
|
3
|
+
// A run reports one total today, which is enough to know it was slow and not
|
|
4
|
+
// enough to know why. The phases have very different cost profiles — capture
|
|
5
|
+
// dominates, diff is a cold path that only runs for stories that already
|
|
6
|
+
// failed, and enumerate/affected are fixed overhead — so the total alone
|
|
7
|
+
// cannot distinguish "700 stories" from "7 stories and a broken diff".
|
|
8
|
+
export function phases() {
|
|
9
|
+
const timings = {};
|
|
10
|
+
const counts = {};
|
|
11
|
+
const add = (name, ms) => {
|
|
12
|
+
timings[name] = (timings[name] ?? 0) + ms;
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
timings,
|
|
16
|
+
counts,
|
|
17
|
+
add,
|
|
18
|
+
count(name, n = 1) {
|
|
19
|
+
counts[name] = (counts[name] ?? 0) + n;
|
|
20
|
+
},
|
|
21
|
+
async time(name, fn) {
|
|
22
|
+
const started = Date.now();
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
add(name, Date.now() - started);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* `enumerate 12ms | capture 48.2s | diff(7) 810ms`
|
|
34
|
+
*
|
|
35
|
+
* Phases are printed in insertion order, which is execution order, so the line
|
|
36
|
+
* reads as the run happened. Sub-millisecond phases are dropped: they are noise
|
|
37
|
+
* next to a capture measured in seconds.
|
|
38
|
+
*/
|
|
39
|
+
export function formatPhases(p) {
|
|
40
|
+
const parts = [];
|
|
41
|
+
for (const [name, ms] of Object.entries(p.timings)) {
|
|
42
|
+
if (ms < 1)
|
|
43
|
+
continue;
|
|
44
|
+
const n = p.counts[name];
|
|
45
|
+
const label = n === undefined ? name : `${name}(${n})`;
|
|
46
|
+
parts.push(`${label} ${ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`}`);
|
|
47
|
+
}
|
|
48
|
+
return parts.join(' | ');
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=phases.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"phases.js","sourceRoot":"","sources":["../../src/run/phases.ts"],"names":[],"mappings":"AAAA,gCAAgC;AAChC,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,yEAAyE;AACzE,yEAAyE;AACzE,uEAAuE;AAavE,MAAM,UAAU,MAAM;IACpB,MAAM,OAAO,GAA2B,EAAE,CAAC;IAC3C,MAAM,MAAM,GAA2B,EAAE,CAAC;IAE1C,MAAM,GAAG,GAAG,CAAC,IAAY,EAAE,EAAU,EAAE,EAAE;QACvC,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,CAAC;IAC5C,CAAC,CAAC;IAEF,OAAO;QACL,OAAO;QACP,MAAM;QACN,GAAG;QACH,KAAK,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC;YACf,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC;QACzC,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE;YACjB,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;YAC3B,IAAI,CAAC;gBACH,OAAO,MAAM,EAAE,EAAE,CAAC;YACpB,CAAC;oBAAS,CAAC;gBACT,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,CAAC;YAClC,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,CAAqC;IAChE,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAI,EAAE,GAAG,CAAC;YAAE,SAAS;QACrB,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;QACzB,MAAM,KAAK,GAAG,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;QACvD,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,IAAI,EAAE,IAAI,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,IAAI,EAAE,CAAC,CAAC;IAClF,CAAC;IACD,OAAO,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AAC3B,CAAC"}
|
package/dist/run/run.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import type { Browser } from '
|
|
1
|
+
import type { Browser } from '../browser/browser.js';
|
|
2
2
|
import type { Platform } from '../platform.js';
|
|
3
|
-
import type { BaselineStore, DiffStatus, ProjectConfig, RunReport, StoryManifest, Viewport } from '../types.js';
|
|
3
|
+
import type { BaselineStore, DiffStatus, ProjectConfig, RunReport, StoryManifest, StructureSnapshot, Viewport } from '../types.js';
|
|
4
4
|
import { type AncestryCheck } from '../baseline/status.js';
|
|
5
|
+
import { type Phases } from './phases.js';
|
|
6
|
+
import type { CauseIndex } from '../analysis/cause.js';
|
|
5
7
|
export declare const DEFAULT_VIEWPORT: Viewport;
|
|
6
8
|
export interface RunOptions {
|
|
7
9
|
browser: Browser;
|
|
@@ -20,11 +22,17 @@ export interface RunOptions {
|
|
|
20
22
|
onArtifact?: (artifact: RunArtifact) => Promise<void>;
|
|
21
23
|
onProgress?: (done: number, total: number, storyId: string) => void;
|
|
22
24
|
signal?: AbortSignal;
|
|
25
|
+
/** Collects per-phase wall time. Pass one to fold in phases the caller timed
|
|
26
|
+
* itself (enumerate, affected, launch); omit and one is made internally. */
|
|
27
|
+
phases?: Phases;
|
|
28
|
+
/** From `jux run --since`: consulted only for a story that failed. */
|
|
29
|
+
cause?: CauseIndex;
|
|
23
30
|
}
|
|
24
31
|
export interface RunArtifact {
|
|
25
32
|
storyId: string;
|
|
26
33
|
status: DiffStatus;
|
|
27
34
|
current: Buffer;
|
|
28
35
|
diff?: Buffer;
|
|
36
|
+
structure?: StructureSnapshot;
|
|
29
37
|
}
|
|
30
38
|
export declare function runCapture(opts: RunOptions): Promise<RunReport>;
|
package/dist/run/run.js
CHANGED
|
@@ -4,15 +4,23 @@
|
|
|
4
4
|
// so it runs for every story. Decoding and diffing are expensive and explain
|
|
5
5
|
// nothing about a passing story, so they run only for stories that already
|
|
6
6
|
// failed. On a green run this function never decodes a single image.
|
|
7
|
+
import { captureEnv } from '../capture/env.js';
|
|
7
8
|
import { capturePool } from '../capture/pool.js';
|
|
8
9
|
import { resolveStatus, toDiffStatus } from '../baseline/status.js';
|
|
9
|
-
import {
|
|
10
|
+
import { decodeStructure } from '../capture/structure.js';
|
|
11
|
+
import { structuralDiff } from '../diff/structural.js';
|
|
12
|
+
import { compareCodeUnits } from '../diff/structural/deltas.js';
|
|
13
|
+
import { computeGroups } from './groups.js';
|
|
14
|
+
import { diffImages, resolveThreshold, resolveMinDiffPercent, } from '../diff/diff.js';
|
|
15
|
+
import { phases } from './phases.js';
|
|
16
|
+
import { shellQuote } from '../shell-quote.js';
|
|
10
17
|
export const DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
11
18
|
export async function runCapture(opts) {
|
|
12
19
|
const startedAt = new Date();
|
|
13
20
|
const started = Date.now();
|
|
14
21
|
const viewport = opts.config?.viewport ?? DEFAULT_VIEWPORT;
|
|
15
|
-
const
|
|
22
|
+
const p = opts.phases ?? phases();
|
|
23
|
+
const manifest = await p.time('baselines', () => opts.store.readManifest());
|
|
16
24
|
const results = [];
|
|
17
25
|
const pool = capturePool(opts.stories, {
|
|
18
26
|
browser: opts.browser,
|
|
@@ -24,6 +32,10 @@ export async function runCapture(opts) {
|
|
|
24
32
|
onProgress: opts.onProgress,
|
|
25
33
|
signal: opts.signal,
|
|
26
34
|
});
|
|
35
|
+
// Capture is measured as the whole streamed loop rather than per story: the
|
|
36
|
+
// pool runs `concurrency` pages at once, so summing per-story durations would
|
|
37
|
+
// report several times the wall-clock actually spent.
|
|
38
|
+
const captureStarted = Date.now();
|
|
27
39
|
for await (const outcome of pool) {
|
|
28
40
|
if (outcome.skipped) {
|
|
29
41
|
results.push({
|
|
@@ -48,23 +60,35 @@ export async function runCapture(opts) {
|
|
|
48
60
|
});
|
|
49
61
|
continue;
|
|
50
62
|
}
|
|
51
|
-
results.push(await judge(outcome.snapshot, outcome.story, manifest, opts));
|
|
63
|
+
results.push(await judge(outcome.snapshot, outcome.story, manifest, opts, p));
|
|
52
64
|
}
|
|
65
|
+
// Diffing and cause tracing happen inside the loop; subtract both to
|
|
66
|
+
// leave capture alone.
|
|
67
|
+
p.add('capture', Date.now() - captureStarted - (p.timings.diff ?? 0) - (p.timings.cause ?? 0));
|
|
53
68
|
const summary = tally(results);
|
|
69
|
+
// Code-unit order: the report is byte-stable across machines, and
|
|
70
|
+
// localeCompare is not.
|
|
71
|
+
results.sort((a, b) => compareCodeUnits(a.storyId, b.storyId));
|
|
72
|
+
const groups = computeGroups(results);
|
|
54
73
|
return {
|
|
55
74
|
runId: opts.runId,
|
|
56
75
|
branch: opts.branch,
|
|
57
76
|
commitSha: opts.commitSha,
|
|
77
|
+
// Carried on the report because `approve` has no browser of its own — it
|
|
78
|
+
// reads captured images off disk, so this is the only path by which the
|
|
79
|
+
// fingerprint reaches a baseline entry.
|
|
80
|
+
env: captureEnv(opts.browser),
|
|
58
81
|
capturedCount: opts.stories.length,
|
|
59
82
|
totalCount: opts.totalCount,
|
|
60
|
-
results
|
|
83
|
+
results,
|
|
61
84
|
summary,
|
|
85
|
+
...(groups.length > 0 ? { groups } : {}),
|
|
62
86
|
durationMs: Date.now() - started,
|
|
63
87
|
startedAt: startedAt.toISOString(),
|
|
64
88
|
};
|
|
65
89
|
}
|
|
66
90
|
/** Decide one story's fate. Cheap path first. */
|
|
67
|
-
async function judge(snapshot, story, manifest, opts) {
|
|
91
|
+
async function judge(snapshot, story, manifest, opts, p) {
|
|
68
92
|
const baselineStatus = resolveStatus(story.storyId, snapshot.image, manifest, opts.ancestry);
|
|
69
93
|
const status = toDiffStatus(baselineStatus);
|
|
70
94
|
const base = {
|
|
@@ -73,38 +97,102 @@ async function judge(snapshot, story, manifest, opts) {
|
|
|
73
97
|
mismatchPixels: 0,
|
|
74
98
|
mismatchPercent: 0,
|
|
75
99
|
changedRegions: [],
|
|
100
|
+
// The effective viewport, including any per-story override. Recording it
|
|
101
|
+
// with the regions means nobody downstream has to re-enumerate stories to
|
|
102
|
+
// find out what the coordinates are relative to.
|
|
103
|
+
viewport: snapshot.viewport,
|
|
76
104
|
durationMs: snapshot.durationMs,
|
|
77
105
|
};
|
|
78
106
|
// pass / new / stale need no pixel work: there is either nothing to explain
|
|
79
107
|
// or nothing to compare against.
|
|
80
108
|
if (status !== 'fail') {
|
|
81
|
-
await opts.onArtifact?.({
|
|
109
|
+
await opts.onArtifact?.({
|
|
110
|
+
storyId: story.storyId,
|
|
111
|
+
status,
|
|
112
|
+
current: snapshot.image,
|
|
113
|
+
structure: snapshot.structure,
|
|
114
|
+
});
|
|
82
115
|
return base;
|
|
83
116
|
}
|
|
84
117
|
const baseline = await opts.store.get(story.storyId);
|
|
85
118
|
if (!baseline) {
|
|
86
119
|
// The manifest claimed a baseline the store can't produce.
|
|
87
|
-
await opts.onArtifact?.({
|
|
120
|
+
await opts.onArtifact?.({
|
|
121
|
+
storyId: story.storyId,
|
|
122
|
+
status,
|
|
123
|
+
current: snapshot.image,
|
|
124
|
+
structure: snapshot.structure,
|
|
125
|
+
});
|
|
88
126
|
return { ...base, error: 'baseline missing from store' };
|
|
89
127
|
}
|
|
90
|
-
|
|
128
|
+
// Counted as well as timed: under a byte-exact hash the number of diffs is
|
|
129
|
+
// how broken the run was, and it is the figure that explains a slow one.
|
|
130
|
+
p.count('diff');
|
|
131
|
+
const diff = await p.time('diff', () => diffImages(baseline, snapshot.image, {
|
|
91
132
|
threshold: resolveThreshold(story.params, opts.config),
|
|
92
133
|
minDiffPercent: resolveMinDiffPercent(story.params, opts.config),
|
|
93
|
-
});
|
|
134
|
+
}));
|
|
94
135
|
await opts.onArtifact?.({
|
|
95
136
|
storyId: story.storyId,
|
|
96
137
|
status,
|
|
97
138
|
current: snapshot.image,
|
|
98
139
|
diff: diff.diffImage,
|
|
140
|
+
structure: snapshot.structure,
|
|
99
141
|
});
|
|
142
|
+
// Structure explains the failure after the pixels have decided it. The
|
|
143
|
+
// baseline sidecar is read only here, so a green run never touches one. A
|
|
144
|
+
// manifest hash is not a file: read the store, and treat anything
|
|
145
|
+
// unreadable as absent. This code runs only on a hash mismatch.
|
|
146
|
+
const structural = await tryStructural(opts, story, snapshot, diff, p);
|
|
100
147
|
return {
|
|
101
148
|
...base,
|
|
102
149
|
mismatchPixels: diff.mismatchPixels,
|
|
103
150
|
mismatchPercent: diff.mismatchPercent,
|
|
104
151
|
changedRegions: diff.changedRegions,
|
|
105
152
|
error: diff.dimensionMismatch ? 'viewport size changed since baseline' : undefined,
|
|
153
|
+
...(structural ? { structural } : {}),
|
|
106
154
|
};
|
|
107
155
|
}
|
|
156
|
+
/** A sidecar that is absent or unreadable is simply no baseline structure. */
|
|
157
|
+
async function readBaselineStructure(store, storyId) {
|
|
158
|
+
try {
|
|
159
|
+
const gz = store.getStructure ? await store.getStructure(storyId) : null;
|
|
160
|
+
return gz ? decodeStructure(gz) : undefined;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Structure explains a failure, never decides one, so nothing in here may
|
|
167
|
+
* fail the story: a missing, corrupt or malformed sidecar, a cause index
|
|
168
|
+
* that throws, or a bug in the diff, yields no verdict (or one without a
|
|
169
|
+
* cause) and the pixel result stands. */
|
|
170
|
+
async function tryStructural(opts, story, snapshot, pixels, p) {
|
|
171
|
+
if (!snapshot.structure)
|
|
172
|
+
return undefined;
|
|
173
|
+
let cause;
|
|
174
|
+
if (opts.cause) {
|
|
175
|
+
try {
|
|
176
|
+
cause = await p.time('cause', () => opts.cause.causeFor(story.componentPath));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
cause = undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return structuralDiff({
|
|
184
|
+
baseline: await readBaselineStructure(opts.store, story.storyId),
|
|
185
|
+
current: snapshot.structure,
|
|
186
|
+
imagesIdentical: false,
|
|
187
|
+
pixels,
|
|
188
|
+
escalate: { command: `jux handoff ${shellQuote(story.storyId)}` },
|
|
189
|
+
...(cause ? { cause } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
108
196
|
function tally(results) {
|
|
109
197
|
const summary = {
|
|
110
198
|
pass: 0,
|
package/dist/run/run.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/run/run.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,gFAAgF;AAChF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;
|
|
1
|
+
{"version":3,"file":"run.js","sourceRoot":"","sources":["../../src/run/run.ts"],"names":[],"mappings":"AAAA,oEAAoE;AACpE,EAAE;AACF,gFAAgF;AAChF,6EAA6E;AAC7E,2EAA2E;AAC3E,qEAAqE;AAGrE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAc/C,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,YAAY,EAAsB,MAAM,uBAAuB,CAAC;AACxF,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EACL,UAAU,EAEV,gBAAgB,EAChB,qBAAqB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,MAAM,EAAe,MAAM,aAAa,CAAC;AAElD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAE/C,MAAM,CAAC,MAAM,gBAAgB,GAAa,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC;AAkCvE,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAgB;IAC/C,MAAM,SAAS,GAAG,IAAI,IAAI,EAAE,CAAC;IAC7B,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,EAAE,QAAQ,IAAI,gBAAgB,CAAC;IAC3D,MAAM,CAAC,GAAG,IAAI,CAAC,MAAM,IAAI,MAAM,EAAE,CAAC;IAClC,MAAM,QAAQ,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,WAAW,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,YAAY,EAAE,CAAC,CAAC;IAC5E,MAAM,OAAO,GAAiB,EAAE,CAAC;IAEjC,MAAM,IAAI,GAAG,WAAW,CAAC,IAAI,CAAC,OAAO,EAAE;QACrC,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,QAAQ,EAAE,IAAI,CAAC,QAAQ;QACvB,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,WAAW,EAAE,IAAI,CAAC,MAAM,EAAE,WAAW;QACrC,UAAU,EAAE,IAAI,CAAC,MAAM,EAAE,UAAU;QACnC,QAAQ;QACR,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IAEH,4EAA4E;IAC5E,8EAA8E;IAC9E,sDAAsD;IACtD,MAAM,cAAc,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IAElC,IAAI,KAAK,EAAE,MAAM,OAAO,IAAI,IAAI,EAAE,CAAC;QACjC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;YACpB,OAAO,CAAC,IAAI,CAAC;gBACX,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,OAAO;gBAC9B,MAAM,EAAE,MAAM;gBACd,cAAc,EAAE,CAAC;gBACjB,eAAe,EAAE,CAAC;gBAClB,cAAc,EAAE,EAAE;gBAClB,UAAU,EAAE,CAAC;aACd,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QAED,IAAI,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC;YACvC,OAAO,CAAC,IAAI,CAAC;gBACX,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,OAAO;gBAC9B,MAAM,EAAE,MAAM;gBACd,cAAc,EAAE,CAAC;gBACjB,eAAe,EAAE,CAAC;gBAClB,cAAc,EAAE,EAAE;gBAClB,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,IAAI,8BAA8B;gBAC/D,UAAU,EAAE,CAAC;aACd,CAAC,CAAC;YACH,SAAS;QACX,CAAC;QAED,OAAO,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,OAAO,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IAChF,CAAC;IAED,qEAAqE;IACrE,uBAAuB;IACvB,CAAC,CAAC,GAAG,CACH,SAAS,EACT,IAAI,CAAC,GAAG,EAAE,GAAG,cAAc,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,CAAC,CAC7E,CAAC;IAEF,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC;IAC/B,kEAAkE;IAClE,wBAAwB;IACxB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,gBAAgB,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAC/D,MAAM,MAAM,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;IAEtC,OAAO;QACL,KAAK,EAAE,IAAI,CAAC,KAAK;QACjB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,yEAAyE;QACzE,wEAAwE;QACxE,wCAAwC;QACxC,GAAG,EAAE,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;QAC7B,aAAa,EAAE,IAAI,CAAC,OAAO,CAAC,MAAM;QAClC,UAAU,EAAE,IAAI,CAAC,UAAU;QAC3B,OAAO;QACP,OAAO;QACP,GAAG,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACxC,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO;QAChC,SAAS,EAAE,SAAS,CAAC,WAAW,EAAE;KACnC,CAAC;AACJ,CAAC;AAED,iDAAiD;AACjD,KAAK,UAAU,KAAK,CAClB,QAAkB,EAClB,KAAoB,EACpB,QAA4D,EAC5D,IAAgB,EAChB,CAAS;IAET,MAAM,cAAc,GAAG,aAAa,CAClC,KAAK,CAAC,OAAO,EACb,QAAQ,CAAC,KAAK,EACd,QAAQ,EACR,IAAI,CAAC,QAAQ,CACd,CAAC;IACF,MAAM,MAAM,GAAG,YAAY,CAAC,cAAc,CAAC,CAAC;IAE5C,MAAM,IAAI,GAAe;QACvB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM;QACN,cAAc,EAAE,CAAC;QACjB,eAAe,EAAE,CAAC;QAClB,cAAc,EAAE,EAAE;QAClB,yEAAyE;QACzE,0EAA0E;QAC1E,iDAAiD;QACjD,QAAQ,EAAE,QAAQ,CAAC,QAAQ;QAC3B,UAAU,EAAE,QAAQ,CAAC,UAAU;KAChC,CAAC;IAEF,4EAA4E;IAC5E,iCAAiC;IACjC,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACtB,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;YACtB,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,MAAM;YACN,OAAO,EAAE,QAAQ,CAAC,KAAK;YACvB,SAAS,EAAE,QAAQ,CAAC,SAAS;SAC9B,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC;IAED,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACrD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,2DAA2D;QAC3D,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;YACtB,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,MAAM;YACN,OAAO,EAAE,QAAQ,CAAC,KAAK;YACvB,SAAS,EAAE,QAAQ,CAAC,SAAS;SAC9B,CAAC,CAAC;QACH,OAAO,EAAE,GAAG,IAAI,EAAE,KAAK,EAAE,6BAA6B,EAAE,CAAC;IAC3D,CAAC;IAED,2EAA2E;IAC3E,yEAAyE;IACzE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IAChB,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,EAAE,CACrC,UAAU,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,EAAE;QACnC,SAAS,EAAE,gBAAgB,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;QACtD,cAAc,EAAE,qBAAqB,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,MAAM,CAAC;KACjE,CAAC,CACH,CAAC;IAEF,MAAM,IAAI,CAAC,UAAU,EAAE,CAAC;QACtB,OAAO,EAAE,KAAK,CAAC,OAAO;QACtB,MAAM;QACN,OAAO,EAAE,QAAQ,CAAC,KAAK;QACvB,IAAI,EAAE,IAAI,CAAC,SAAS;QACpB,SAAS,EAAE,QAAQ,CAAC,SAAS;KAC9B,CAAC,CAAC;IAEH,uEAAuE;IACvE,0EAA0E;IAC1E,kEAAkE;IAClE,gEAAgE;IAChE,MAAM,UAAU,GAAG,MAAM,aAAa,CAAC,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;IAEvE,OAAO;QACL,GAAG,IAAI;QACP,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,eAAe,EAAE,IAAI,CAAC,eAAe;QACrC,cAAc,EAAE,IAAI,CAAC,cAAc;QACnC,KAAK,EAAE,IAAI,CAAC,iBAAiB,CAAC,CAAC,CAAC,sCAAsC,CAAC,CAAC,CAAC,SAAS;QAClF,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACtC,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,KAAK,UAAU,qBAAqB,CAClC,KAAoB,EACpB,OAAe;IAEf,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACzE,OAAO,EAAE,CAAC,CAAC,CAAC,eAAe,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC9C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;0CAG0C;AAC1C,KAAK,UAAU,aAAa,CAC1B,IAAgB,EAChB,KAAoB,EACpB,QAAkB,EAClB,MAAgE,EAChE,CAAS;IAET,IAAI,CAAC,QAAQ,CAAC,SAAS;QAAE,OAAO,SAAS,CAAC;IAC1C,IAAI,KAA8D,CAAC;IACnE,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,IAAI,CAAC;YACH,KAAK,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,KAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,aAAa,CAAC,CAAC,CAAC;QACjF,CAAC;QAAC,MAAM,CAAC;YACP,KAAK,GAAG,SAAS,CAAC;QACpB,CAAC;IACH,CAAC;IACD,IAAI,CAAC;QACH,OAAO,cAAc,CAAC;YACpB,QAAQ,EAAE,MAAM,qBAAqB,CAAC,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC;YAChE,OAAO,EAAE,QAAQ,CAAC,SAAS;YAC3B,eAAe,EAAE,KAAK;YACtB,MAAM;YACN,QAAQ,EAAE,EAAE,OAAO,EAAE,eAAe,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,EAAE;YACjE,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC5B,CAAC,CAAC;IACL,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED,SAAS,KAAK,CAAC,OAAqB;IAClC,MAAM,OAAO,GAA+B;QAC1C,IAAI,EAAE,CAAC;QACP,IAAI,EAAE,CAAC;QACP,GAAG,EAAE,CAAC;QACN,KAAK,EAAE,CAAC;QACR,IAAI,EAAE,CAAC;KACR,CAAC;IACF,KAAK,MAAM,CAAC,IAAI,OAAO;QAAE,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC;IAC7C,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Every hint that prints a command prints one an agent can paste. A snap
|
|
2
|
+
// name with a space (`jux diff 'a b' x`) printed bare made the suggested
|
|
3
|
+
// `jux snap <url> --as a b` a different command. In core so the verdict's
|
|
4
|
+
// `escalate.command` and the CLI's hints share one implementation.
|
|
5
|
+
const SHELL_SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
6
|
+
/** Single-quote a value for a POSIX shell unless it is plainly safe. */
|
|
7
|
+
export function shellQuote(v) {
|
|
8
|
+
return SHELL_SAFE.test(v) ? v : `'${v.replaceAll("'", "'\\''")}'`;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=shell-quote.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shell-quote.js","sourceRoot":"","sources":["../src/shell-quote.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,yEAAyE;AACzE,0EAA0E;AAC1E,mEAAmE;AAEnE,MAAM,UAAU,GAAG,0BAA0B,CAAC;AAE9C,wEAAwE;AACxE,MAAM,UAAU,UAAU,CAAC,CAAS;IAClC,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,GAAG,CAAC;AACpE,CAAC"}
|