bugpatrol 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +15544 -4495
- package/package.json +1 -1
- package/src/ui/app.js +1264 -207
- package/src/ui/index.html +11 -1
- package/src/ui/style.css +665 -16
package/src/ui/app.js
CHANGED
|
@@ -8,11 +8,47 @@ const view = document.getElementById('view');
|
|
|
8
8
|
const tabs = document.getElementById('tabs');
|
|
9
9
|
const liveEl = document.getElementById('live');
|
|
10
10
|
const projectEl = document.getElementById('project-name');
|
|
11
|
-
const
|
|
11
|
+
const surfacesEl = document.getElementById('surfaces');
|
|
12
12
|
const patrolEl = document.getElementById('patrol');
|
|
13
|
+
const themeEl = document.getElementById('theme');
|
|
14
|
+
|
|
15
|
+
// ---------- theme ----------
|
|
16
|
+
// The choice cycles system → light → dark. "System" follows the OS setting, also when it changes.
|
|
17
|
+
const THEMES = ['system', 'light', 'dark'];
|
|
18
|
+
const lightQuery = matchMedia('(prefers-color-scheme: light)');
|
|
19
|
+
let themeChoice = THEMES.includes(localStorage.getItem('bugpatrol-theme'))
|
|
20
|
+
? localStorage.getItem('bugpatrol-theme')
|
|
21
|
+
: 'system';
|
|
22
|
+
// Line icons for the top bar. The theme button and the surfaces show icons, and their labels hold the words.
|
|
23
|
+
const MONITOR_ICON = '<rect x="2" y="3" width="12" height="8" rx="1"/><path d="M6 14h4M8 11v3"/>';
|
|
24
|
+
const PHONE_ICON = '<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/><path d="M7 12.5h2"/>';
|
|
25
|
+
const themeIcons = {
|
|
26
|
+
system: MONITOR_ICON,
|
|
27
|
+
light:
|
|
28
|
+
'<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/>',
|
|
29
|
+
dark: '<path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z"/>',
|
|
30
|
+
};
|
|
31
|
+
function applyTheme() {
|
|
32
|
+
const theme = themeChoice === 'system' ? (lightQuery.matches ? 'light' : 'dark') : themeChoice;
|
|
33
|
+
document.documentElement.dataset.theme = theme;
|
|
34
|
+
const next = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
35
|
+
const label = { system: 'System', light: 'Light', dark: 'Dark' };
|
|
36
|
+
themeEl.innerHTML = iconSvg(themeIcons[themeChoice]);
|
|
37
|
+
themeEl.setAttribute('aria-label', `Theme: ${label[themeChoice]}. Change the theme to ${label[next]}`);
|
|
38
|
+
themeEl.title = `Theme: ${label[themeChoice]}. Click for ${label[next]}.`;
|
|
39
|
+
}
|
|
40
|
+
themeEl.addEventListener('click', () => {
|
|
41
|
+
themeChoice = THEMES[(THEMES.indexOf(themeChoice) + 1) % THEMES.length];
|
|
42
|
+
if (themeChoice === 'system') localStorage.removeItem('bugpatrol-theme');
|
|
43
|
+
else localStorage.setItem('bugpatrol-theme', themeChoice);
|
|
44
|
+
applyTheme();
|
|
45
|
+
});
|
|
46
|
+
lightQuery.addEventListener('change', applyTheme);
|
|
47
|
+
applyTheme();
|
|
13
48
|
|
|
14
49
|
const state = {
|
|
15
50
|
view: 'overview',
|
|
51
|
+
summary: null,
|
|
16
52
|
overview: null,
|
|
17
53
|
issues: [],
|
|
18
54
|
issueFilter: 'open',
|
|
@@ -26,6 +62,9 @@ const state = {
|
|
|
26
62
|
selectedScreenId: null,
|
|
27
63
|
routines: [],
|
|
28
64
|
memory: { lessons: [] },
|
|
65
|
+
coverage: null,
|
|
66
|
+
coverageFilter: { failedOnly: false, hideSkipped: false, query: '' },
|
|
67
|
+
selectedCell: null,
|
|
29
68
|
runs: [],
|
|
30
69
|
selectedRunId: null,
|
|
31
70
|
reviews: [],
|
|
@@ -74,9 +113,9 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
74
113
|
state.runs = snapshot.runs;
|
|
75
114
|
state.live = snapshot.live;
|
|
76
115
|
state.root = snapshot.root;
|
|
116
|
+
state.summary = await getJson('/api/summary');
|
|
77
117
|
state.overview = await getJson('/api/overview');
|
|
78
|
-
|
|
79
|
-
platformEl.textContent = state.overview.project.platform;
|
|
118
|
+
renderHeader();
|
|
80
119
|
const patrol = state.overview.patrol;
|
|
81
120
|
patrolEl.textContent = patrol.state === 'running' ? `Patrol running · cycle ${patrol.cycle}` : 'Patrol stopped';
|
|
82
121
|
patrolEl.classList.toggle('running', patrol.state === 'running');
|
|
@@ -105,7 +144,7 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
105
144
|
state.issueDetail = null;
|
|
106
145
|
}
|
|
107
146
|
}
|
|
108
|
-
if (state.view === 'activity') {
|
|
147
|
+
if (state.view === 'activity' || state.view === 'flow') {
|
|
109
148
|
state.sessions = await getJson('/api/sessions');
|
|
110
149
|
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
111
150
|
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
@@ -117,10 +156,6 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
117
156
|
}
|
|
118
157
|
}
|
|
119
158
|
if (state.view === 'flow') {
|
|
120
|
-
state.sessions = await getJson('/api/sessions');
|
|
121
|
-
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
122
|
-
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
123
|
-
}
|
|
124
159
|
state.flow = state.selectedSessionId
|
|
125
160
|
? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
|
|
126
161
|
: null;
|
|
@@ -135,6 +170,14 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
135
170
|
}
|
|
136
171
|
}
|
|
137
172
|
if (state.view === 'memory') state.memory = await getJson('/api/memory');
|
|
173
|
+
if (state.view === 'coverage') {
|
|
174
|
+
state.coverage = state.summary.coverage.api ? await getJson('/api/coverage') : null;
|
|
175
|
+
if (!state.coverage?.cells[state.selectedCell]?.exchange) state.selectedCell = null;
|
|
176
|
+
if (state.summary.coverage.screens) {
|
|
177
|
+
state.appmap = await getJson('/api/appmap');
|
|
178
|
+
state.issues = await getJson('/api/issues');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
138
181
|
if (state.view === 'reviews') {
|
|
139
182
|
const reviews = await getJson('/api/reviews');
|
|
140
183
|
if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
|
|
@@ -177,9 +220,15 @@ function render() {
|
|
|
177
220
|
state.selectedReview,
|
|
178
221
|
state.selectedRunId,
|
|
179
222
|
state.selectedScreenId,
|
|
223
|
+
state.selectedCell,
|
|
180
224
|
]);
|
|
181
225
|
const opened = page === renderedPage ? detailsOpen() : undefined;
|
|
182
226
|
const scroll = window.scrollY;
|
|
227
|
+
// A scroll box, such as the coverage matrix, keeps its place too.
|
|
228
|
+
const boxes = opened
|
|
229
|
+
? [...view.querySelectorAll('[data-keep-scroll]')].map((box) => [box.scrollLeft, box.scrollTop])
|
|
230
|
+
: [];
|
|
231
|
+
const typing = opened && document.activeElement?.matches('input[type="search"]');
|
|
183
232
|
renderedPage = page;
|
|
184
233
|
view.innerHTML = '';
|
|
185
234
|
if (state.view === 'checks') {
|
|
@@ -194,11 +243,15 @@ function render() {
|
|
|
194
243
|
screens: renderScreens,
|
|
195
244
|
reviews: renderReviews,
|
|
196
245
|
memory: renderMemory,
|
|
246
|
+
coverage: renderCoverage,
|
|
197
247
|
checks: renderRuns,
|
|
198
248
|
};
|
|
199
249
|
view.append(renderers[state.view]());
|
|
200
250
|
if (opened) {
|
|
201
251
|
for (const [key, details] of detailsByKey()) if (opened.has(key)) details.open = opened.get(key);
|
|
252
|
+
for (const [index, box] of [...view.querySelectorAll('[data-keep-scroll]')].entries())
|
|
253
|
+
if (boxes[index]) [box.scrollLeft, box.scrollTop] = boxes[index];
|
|
254
|
+
if (typing) view.querySelector('input[type="search"]')?.focus();
|
|
202
255
|
window.scrollTo(0, scroll);
|
|
203
256
|
}
|
|
204
257
|
}
|
|
@@ -561,12 +614,17 @@ const roleIcons = {
|
|
|
561
614
|
decider: '<path d="m3 8 3 3 7-7"/>',
|
|
562
615
|
};
|
|
563
616
|
|
|
617
|
+
/** A 16×16 line icon from trusted path markup in this file. */
|
|
618
|
+
function iconSvg(paths) {
|
|
619
|
+
return (
|
|
620
|
+
`<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" ` +
|
|
621
|
+
`stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
564
625
|
function roleIcon(role) {
|
|
565
626
|
const icon = roleIcons[role] ?? roleIcons.system;
|
|
566
|
-
|
|
567
|
-
`<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" ` +
|
|
568
|
-
`stroke-width="1.4" aria-hidden="true">${icon}</svg>`;
|
|
569
|
-
return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: svg });
|
|
627
|
+
return el('span', { class: 'role-icon', title: role, 'aria-label': role, html: iconSvg(icon) });
|
|
570
628
|
}
|
|
571
629
|
|
|
572
630
|
function relativeTime(value) {
|
|
@@ -642,8 +700,26 @@ function renderUsage(usage) {
|
|
|
642
700
|
]);
|
|
643
701
|
}
|
|
644
702
|
|
|
645
|
-
|
|
703
|
+
/** A capture file: a screenshot, or the exchange file of an API, which shows as text. */
|
|
704
|
+
function captureFile(path, label, className = '') {
|
|
646
705
|
if (!path) return el('div', { class: `image-empty ${className}`, text: 'No capture' });
|
|
706
|
+
if (path.endsWith('.exchange.json')) {
|
|
707
|
+
const small = isThumb(className);
|
|
708
|
+
const view = small
|
|
709
|
+
? httpThumb(undefined, label, className)
|
|
710
|
+
: el('pre', { class: `http ${className}`, title: label, text: 'Loading the exchange…' });
|
|
711
|
+
// The server formats the exchange, so the dashboard shows the same text as the models and issues.
|
|
712
|
+
fetch(`/api/exchange?path=${encodeURIComponent(path)}`)
|
|
713
|
+
.then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
|
|
714
|
+
.then((text) => {
|
|
715
|
+
if (small) view.textContent = text.match(/^HTTP \d{3}/m)?.[0] ?? 'HTTP';
|
|
716
|
+
else view.textContent = text;
|
|
717
|
+
})
|
|
718
|
+
.catch(() => {
|
|
719
|
+
if (!small) view.textContent = 'The exchange is not available.';
|
|
720
|
+
});
|
|
721
|
+
return view;
|
|
722
|
+
}
|
|
647
723
|
return el('img', {
|
|
648
724
|
class: className,
|
|
649
725
|
src: artifact(path),
|
|
@@ -653,6 +729,20 @@ function image(path, label, className = '') {
|
|
|
653
729
|
});
|
|
654
730
|
}
|
|
655
731
|
|
|
732
|
+
/** The capture of an issue: its screenshot, or the HTTP exchange of an API as the server formatted it. */
|
|
733
|
+
function captureView(capture, label, className = '') {
|
|
734
|
+
if (capture?.kind !== 'http') return captureFile(capture?.screenshot, label, className);
|
|
735
|
+
if (isThumb(className)) return httpThumb(capture.exchange?.response?.status, label, className);
|
|
736
|
+
return el('pre', { class: `http ${className}`, title: label, text: capture.text });
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const isThumb = (className) => /\b(?:thumb|event-thumb)\b/.test(className);
|
|
740
|
+
|
|
741
|
+
/** A thumbnail is too small for the exchange text, so it shows the status only. */
|
|
742
|
+
function httpThumb(status, label, className) {
|
|
743
|
+
return el('div', { class: `http-thumb ${className}`, title: label, text: status ? `HTTP ${status}` : 'HTTP' });
|
|
744
|
+
}
|
|
745
|
+
|
|
656
746
|
function severityChip(value) {
|
|
657
747
|
return el('span', { class: `severity severity-${value}`, text: value });
|
|
658
748
|
}
|
|
@@ -664,6 +754,190 @@ function title(text, aside) {
|
|
|
664
754
|
]);
|
|
665
755
|
}
|
|
666
756
|
|
|
757
|
+
// ---------------------------------------------------------------- shared blocks
|
|
758
|
+
|
|
759
|
+
const DOCS = 'https://github.com/agent-labs-dev/bugpatrol/blob/main/docs';
|
|
760
|
+
|
|
761
|
+
const PLATFORM_WORDS = {
|
|
762
|
+
api: 'API',
|
|
763
|
+
web: 'Web app',
|
|
764
|
+
electron: 'Electron app',
|
|
765
|
+
desktop: 'Desktop app',
|
|
766
|
+
ios: 'iOS app',
|
|
767
|
+
android: 'Android app',
|
|
768
|
+
cli: 'CLI app',
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const PLATFORM_ICONS = {
|
|
772
|
+
api: '<path d="M5.5 3C4 3 4 4 4 5.5S3 8 2.5 8C3 8 4 8.5 4 10.5S4 13 5.5 13M10.5 3C12 3 12 4 12 5.5S13 8 13.5 8C13 8 12 8.5 12 10.5S12 13 10.5 13"/>',
|
|
773
|
+
web: '<circle cx="8" cy="8" r="6"/><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12"/>',
|
|
774
|
+
electron: MONITOR_ICON,
|
|
775
|
+
desktop: MONITOR_ICON,
|
|
776
|
+
ios: PHONE_ICON,
|
|
777
|
+
android: PHONE_ICON,
|
|
778
|
+
cli: '<rect x="1.5" y="2.5" width="13" height="11" rx="1"/><path d="m4.5 6 2 2-2 2M8 10h3.5"/>',
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const surfaceName = (surface) => PLATFORM_WORDS[surface.platform] ?? surface.platform;
|
|
782
|
+
const fileName = (path) => path?.split('/').pop() ?? '';
|
|
783
|
+
|
|
784
|
+
function surfacesOf(kind) {
|
|
785
|
+
return (state.summary?.surfaces ?? []).filter((surface) => surface.kind === kind);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** The project's surfaces in the top bar. The surface of the config that the dashboard reads is marked. */
|
|
789
|
+
function renderHeader() {
|
|
790
|
+
const summary = state.summary;
|
|
791
|
+
projectEl.textContent = summary.name;
|
|
792
|
+
surfacesEl.replaceChildren(
|
|
793
|
+
...summary.surfaces.map((surface) => {
|
|
794
|
+
const configs = surface.configs.length
|
|
795
|
+
? `${surface.active ? 'The dashboard reads' : 'Config'}: ${surface.configs.join(', ')}`
|
|
796
|
+
: 'From the data. No config file tests it now.';
|
|
797
|
+
const name = surfaceName(surface);
|
|
798
|
+
return el('span', {
|
|
799
|
+
class: `surface${surface.active ? ' active' : ''}`,
|
|
800
|
+
role: 'img',
|
|
801
|
+
'aria-label': `${name}. ${configs}`,
|
|
802
|
+
title: `${name}. ${configs}`,
|
|
803
|
+
html: iconSvg(PLATFORM_ICONS[surface.platform] ?? PLATFORM_ICONS.web),
|
|
804
|
+
});
|
|
805
|
+
}),
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** A command for the config that the dashboard reads, or for the config of another surface. */
|
|
810
|
+
function cli(args, surface) {
|
|
811
|
+
const config =
|
|
812
|
+
surface && !surface.active && surface.configs[0]
|
|
813
|
+
? ` --config ${surface.configs[0]}`
|
|
814
|
+
: (state.summary?.configArg ?? '');
|
|
815
|
+
return `bugpatrol ${args}${config}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** A command with a copy button. */
|
|
819
|
+
function commandBox(text) {
|
|
820
|
+
const button = el('button', {
|
|
821
|
+
type: 'button',
|
|
822
|
+
class: 'copy',
|
|
823
|
+
text: 'Copy',
|
|
824
|
+
'aria-label': `Copy the command ${text}`,
|
|
825
|
+
});
|
|
826
|
+
button.addEventListener('click', async () => {
|
|
827
|
+
try {
|
|
828
|
+
await navigator.clipboard.writeText(text);
|
|
829
|
+
button.textContent = 'Copied';
|
|
830
|
+
} catch {
|
|
831
|
+
button.textContent = 'Copy failed';
|
|
832
|
+
}
|
|
833
|
+
setTimeout(() => {
|
|
834
|
+
button.textContent = 'Copy';
|
|
835
|
+
}, 1500);
|
|
836
|
+
});
|
|
837
|
+
return el('div', { class: 'command' }, [el('code', { text }), button]);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The one empty state of the dashboard: what the block shows, why it is
|
|
842
|
+
* empty now, and the one next step, as a command or a button.
|
|
843
|
+
*/
|
|
844
|
+
function emptyState({ heading, why = [], command, action, docs, compact = false }) {
|
|
845
|
+
return el('div', { class: `empty-state${compact ? ' compact' : ''}` }, [
|
|
846
|
+
el('h3', { text: heading }),
|
|
847
|
+
...[].concat(why).map((text) => el('p', { text })),
|
|
848
|
+
command ? commandBox(command) : null,
|
|
849
|
+
action ? el('button', { type: 'button', class: 'primary', text: action.label, onclick: action.onclick }) : null,
|
|
850
|
+
docs
|
|
851
|
+
? el('a', { class: 'docs', href: docs, target: '_blank', rel: 'noopener noreferrer', text: 'Read the docs' })
|
|
852
|
+
: null,
|
|
853
|
+
]);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** A note with one command, for a block that has data but also a next step. */
|
|
857
|
+
function callout(text, command) {
|
|
858
|
+
return el('div', { class: 'callout' }, [el('p', { text }), commandBox(command)]);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const number = (value) => Number(value ?? 0).toLocaleString('en-US');
|
|
862
|
+
|
|
863
|
+
function percent(part, whole) {
|
|
864
|
+
if (!whole) return '0%';
|
|
865
|
+
const value = (part / whole) * 100;
|
|
866
|
+
return value > 0 && value < 1 ? '<1%' : `${Math.round(value)}%`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A row of numbers. A stat with an action is a button. */
|
|
870
|
+
function statBand(stats, className = '') {
|
|
871
|
+
return el(
|
|
872
|
+
'div',
|
|
873
|
+
{ class: `band ${className}` },
|
|
874
|
+
stats.filter(Boolean).map(({ label, value, note, tone, bar, go }) =>
|
|
875
|
+
el(
|
|
876
|
+
go ? 'button' : 'div',
|
|
877
|
+
{
|
|
878
|
+
class: `stat${tone ? ` tone-${tone}` : ''}`,
|
|
879
|
+
...(go ? { type: 'button', onclick: go } : {}),
|
|
880
|
+
},
|
|
881
|
+
[
|
|
882
|
+
el('span', { class: 'stat-label', text: label }),
|
|
883
|
+
el('strong', { class: 'stat-value', text: String(value) }),
|
|
884
|
+
bar ?? null,
|
|
885
|
+
note ? el('span', { class: 'stat-note', title: note, text: note }) : null,
|
|
886
|
+
],
|
|
887
|
+
),
|
|
888
|
+
),
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const CELL_STATES = ['pass', 'fail', 'blocked', 'skip', 'untested'];
|
|
893
|
+
|
|
894
|
+
/** The cases of each state as one stacked bar. */
|
|
895
|
+
function caseBar(cases) {
|
|
896
|
+
const states = CELL_STATES.filter((name) => cases[name]);
|
|
897
|
+
const total = states.reduce((sum, name) => sum + cases[name], 0);
|
|
898
|
+
return el(
|
|
899
|
+
'div',
|
|
900
|
+
{ class: 'bar', role: 'img', 'aria-label': states.map((name) => `${number(cases[name])} ${name}`).join(', ') },
|
|
901
|
+
states.map((name) =>
|
|
902
|
+
el('span', {
|
|
903
|
+
class: `bar-${name}`,
|
|
904
|
+
style: `flex-grow: ${cases[name] / total}`,
|
|
905
|
+
title: `${number(cases[name])} ${name}`,
|
|
906
|
+
}),
|
|
907
|
+
),
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function caseLegend(cases) {
|
|
912
|
+
return el(
|
|
913
|
+
'div',
|
|
914
|
+
{ class: 'bar-legend' },
|
|
915
|
+
CELL_STATES.filter((name) => cases[name] !== undefined).map((name) =>
|
|
916
|
+
el('span', {}, [el('i', { class: `bar-${name}` }), `${number(cases[name])} ${name}`]),
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function go(view) {
|
|
922
|
+
state.view = view;
|
|
923
|
+
if (view === 'screens') state.selectedScreenId = null;
|
|
924
|
+
await refresh();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async function openSession(id) {
|
|
928
|
+
state.view = 'activity';
|
|
929
|
+
state.selectedSessionId = id;
|
|
930
|
+
await refresh();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function formatDuration(ms) {
|
|
934
|
+
if (ms === undefined) return '-';
|
|
935
|
+
const seconds = Math.round(ms / 1000);
|
|
936
|
+
if (seconds < 60) return `${seconds}s`;
|
|
937
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
938
|
+
return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
939
|
+
}
|
|
940
|
+
|
|
667
941
|
const CI_WORDS = { pending: 'CI running', passed: 'CI green', failed: 'CI failed, fixing', 'gave-up': 'CI red' };
|
|
668
942
|
|
|
669
943
|
function githubChip({ kind, number, url, state, draft, ci }) {
|
|
@@ -689,7 +963,8 @@ function issueChips(issue) {
|
|
|
689
963
|
|
|
690
964
|
function renderOverview() {
|
|
691
965
|
const data = state.overview;
|
|
692
|
-
|
|
966
|
+
const summary = state.summary;
|
|
967
|
+
if (!data || !summary) return el('div', { class: 'empty', text: 'Loading overview…' });
|
|
693
968
|
const agents = el(
|
|
694
969
|
'div',
|
|
695
970
|
{ class: 'agent-grid' },
|
|
@@ -718,77 +993,271 @@ function renderOverview() {
|
|
|
718
993
|
]),
|
|
719
994
|
),
|
|
720
995
|
);
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
el('span', { class: 'grow' }, [
|
|
731
|
-
el('strong', { text: issue.title }),
|
|
732
|
-
el('span', { class: 'row-meta' }, [
|
|
733
|
-
el('small', { class: 'muted', text: `${screenName(issue.screenId)} · ×${issue.occurrences}` }),
|
|
734
|
-
...issueChips(issue),
|
|
735
|
-
]),
|
|
736
|
-
]),
|
|
737
|
-
severityChip(issue.severity),
|
|
738
|
-
el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
|
|
739
|
-
]),
|
|
740
|
-
),
|
|
741
|
-
)
|
|
742
|
-
: el('p', { class: 'empty', text: 'No open issues. The agents found nothing wrong yet.' }),
|
|
996
|
+
return el('div', {}, [
|
|
997
|
+
renderConfidence(summary),
|
|
998
|
+
el('div', { class: 'overview-grid' }, [
|
|
999
|
+
renderAttention(data, summary),
|
|
1000
|
+
el('div', { class: 'stack-gap' }, [data.live ? renderLive(data.live) : null, renderRecentSessions(data)]),
|
|
1001
|
+
]),
|
|
1002
|
+
renderSurfaceCoverage(data, summary),
|
|
1003
|
+
agents,
|
|
1004
|
+
renderUsage(data.usage),
|
|
743
1005
|
]);
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
:
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** The numbers that say if the project works: the verdict, the open problems, the coverage, and the last session. */
|
|
1009
|
+
function renderConfidence(summary) {
|
|
1010
|
+
const { issues, waiting, coverage, lastSession, verdict } = summary;
|
|
1011
|
+
const severe = issues.bySeverity.critical + issues.bySeverity.major;
|
|
1012
|
+
const api = coverage.api;
|
|
1013
|
+
return statBand(
|
|
1014
|
+
[
|
|
1015
|
+
{ label: 'Status', value: verdict.text, tone: verdict.tone },
|
|
1016
|
+
{
|
|
1017
|
+
label: 'Open issues',
|
|
1018
|
+
value: number(issues.open),
|
|
1019
|
+
note: severe ? `${severe} critical or major` : issues.open ? 'None is critical or major' : 'None',
|
|
1020
|
+
tone: severe ? 'fail' : undefined,
|
|
1021
|
+
go: () => go('issues'),
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
label: 'Wait for the judge',
|
|
1025
|
+
value: number(waiting.total),
|
|
1026
|
+
note: waiting.repeat
|
|
1027
|
+
? `${number(waiting.repeat)} failed two times`
|
|
1028
|
+
: waiting.total
|
|
1029
|
+
? 'The judge did not read them yet'
|
|
1030
|
+
: 'None',
|
|
1031
|
+
tone: waiting.total ? 'warn' : undefined,
|
|
1032
|
+
go: () => go('activity'),
|
|
1033
|
+
},
|
|
1034
|
+
surfacesOf('api').length
|
|
1035
|
+
? {
|
|
1036
|
+
label: 'API coverage',
|
|
1037
|
+
value: api ? percent(api.tested, api.endpoints) : '-',
|
|
1038
|
+
note: api ? `${number(api.tested)} of ${number(api.endpoints)} endpoints tested` : 'No sweep yet',
|
|
1039
|
+
go: () => go('coverage'),
|
|
1040
|
+
}
|
|
1041
|
+
: null,
|
|
1042
|
+
surfacesOf('client').length
|
|
1043
|
+
? {
|
|
1044
|
+
label: 'Screens',
|
|
1045
|
+
value: number(coverage.screens?.found),
|
|
1046
|
+
note: coverage.screens?.found ? `${coverage.screens.withIssues} with open issues` : 'No screens yet',
|
|
1047
|
+
go: () => go('coverage'),
|
|
1048
|
+
}
|
|
1049
|
+
: null,
|
|
1050
|
+
{
|
|
1051
|
+
label: 'Last session',
|
|
1052
|
+
value: lastSession ? (lastSession.status === 'running' ? 'Running' : relativeTime(lastSession.startedAt)) : '-',
|
|
1053
|
+
note: lastSession
|
|
1054
|
+
? firstSentence(lastSession.summary) || `${capital(lastSession.role)} session`
|
|
1055
|
+
: 'No session yet',
|
|
1056
|
+
go: lastSession ? () => openSession(lastSession.id) : undefined,
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
'confidence',
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** The open issues, then the findings that wait for the judge. */
|
|
1064
|
+
function renderAttention(data, summary) {
|
|
1065
|
+
const { issues, waiting } = summary;
|
|
1066
|
+
const tested = summary.verdict.tone !== 'info';
|
|
1067
|
+
const issueRows = data.attention.map((issue) =>
|
|
1068
|
+
el('button', { class: 'attention-row', onclick: () => openIssue(issue.id) }, [
|
|
1069
|
+
captureView(issue.evidence?.capture, issue.title, 'thumb'),
|
|
1070
|
+
el('span', { class: 'grow' }, [
|
|
1071
|
+
el('strong', { text: issue.title }),
|
|
1072
|
+
el('span', { class: 'row-meta' }, [
|
|
1073
|
+
el('small', { class: 'muted', text: `${issueWhere(issue)} · ×${issue.occurrences}` }),
|
|
1074
|
+
...issueChips(issue),
|
|
1075
|
+
]),
|
|
1076
|
+
]),
|
|
1077
|
+
severityChip(issue.severity),
|
|
1078
|
+
el('span', { class: 'muted', text: issueStatus(issue, issue.fix) }),
|
|
1079
|
+
]),
|
|
1080
|
+
);
|
|
1081
|
+
const waitingRows = data.waiting.slice(0, 5).map((candidate) =>
|
|
1082
|
+
el('button', { class: 'attention-row', onclick: () => openSession(candidate.sessionId) }, [
|
|
1083
|
+
captureView(candidate.capture, candidate.summary, 'thumb'),
|
|
1084
|
+
el('span', { class: 'grow' }, [
|
|
1085
|
+
el('strong', { class: 'truncate', text: candidate.summary }),
|
|
1086
|
+
el('small', {
|
|
1087
|
+
class: 'muted',
|
|
1088
|
+
text: `${candidate.blocking ? 'Failed two times' : capital(candidate.source)} · ${relativeTime(candidate.createdAt)}`,
|
|
1089
|
+
}),
|
|
1090
|
+
]),
|
|
1091
|
+
severityChip(candidate.severity),
|
|
1092
|
+
]),
|
|
1093
|
+
);
|
|
1094
|
+
return el('section', { class: 'card panel' }, [
|
|
1095
|
+
title('Needs attention', `${number(issues.open)} open · ${number(waiting.total)} wait for the judge`),
|
|
1096
|
+
issueRows.length || waitingRows.length
|
|
1097
|
+
? null
|
|
1098
|
+
: emptyState({
|
|
1099
|
+
heading: tested ? 'Nothing needs attention' : 'Not tested yet',
|
|
1100
|
+
why: tested
|
|
1101
|
+
? 'No issue is open, and no finding waits for the judge.'
|
|
1102
|
+
: 'A patrol tests the project. The explorer reports findings, and the judge files the real problems as issues.',
|
|
1103
|
+
command: cli('patrol --once'),
|
|
1104
|
+
compact: true,
|
|
1105
|
+
}),
|
|
1106
|
+
issueRows.length ? el('div', { class: 'stack' }, issueRows) : null,
|
|
1107
|
+
waitingRows.length
|
|
1108
|
+
? el('div', { class: 'subsection' }, [
|
|
1109
|
+
el('h3', { text: `Findings that wait for the judge (${number(waiting.total)})` }),
|
|
1110
|
+
callout(
|
|
1111
|
+
'The judge reads each finding and files the real problems as issues. Run the judge now:',
|
|
1112
|
+
cli('patrol --once'),
|
|
1113
|
+
),
|
|
1114
|
+
el('div', { class: 'stack' }, waitingRows),
|
|
1115
|
+
waiting.total > waitingRows.length
|
|
1116
|
+
? el('button', {
|
|
1117
|
+
type: 'button',
|
|
1118
|
+
class: 'link',
|
|
1119
|
+
text: `Show all ${number(waiting.total)} findings in Activity`,
|
|
1120
|
+
onclick: () => go('activity'),
|
|
766
1121
|
})
|
|
767
1122
|
: null,
|
|
768
|
-
])
|
|
1123
|
+
])
|
|
1124
|
+
: null,
|
|
1125
|
+
]);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function renderLive(live) {
|
|
1129
|
+
return el('section', { class: 'card panel' }, [
|
|
1130
|
+
title('Live', 'Session running'),
|
|
1131
|
+
captureFile(live.capture, 'Newest session capture', 'live-shot'),
|
|
1132
|
+
el('div', { class: 'event-list' }, live.events.map(renderEventRow)),
|
|
769
1133
|
]);
|
|
770
|
-
|
|
771
|
-
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** One coverage block for each surface: the endpoints of an API, the screens of a client app. */
|
|
1137
|
+
function renderSurfaceCoverage(data, summary) {
|
|
1138
|
+
const blocks = summary.surfaces.map((surface) =>
|
|
1139
|
+
el('div', { class: 'surface-block' }, [
|
|
1140
|
+
el('div', { class: 'surface-head' }, [
|
|
1141
|
+
el('h3', { text: surfaceName(surface) }),
|
|
1142
|
+
surface.configs[0] ? el('span', { class: 'muted mono', text: fileName(surface.configs[0]) }) : null,
|
|
1143
|
+
]),
|
|
1144
|
+
surface.kind === 'api'
|
|
1145
|
+
? apiCoverageBlock(summary.coverage.api, surface)
|
|
1146
|
+
: screenCoverageBlock(data, summary, surface),
|
|
1147
|
+
]),
|
|
1148
|
+
);
|
|
1149
|
+
return el('section', { class: 'card panel block' }, [
|
|
1150
|
+
title('Coverage', summary.surfaces.length > 1 ? 'For each surface' : ''),
|
|
772
1151
|
data.github ? el('div', { class: 'muted github-summary', text: `GitHub · ${data.github}` }) : null,
|
|
1152
|
+
blocks.length
|
|
1153
|
+
? el('div', { class: 'surface-list' }, blocks)
|
|
1154
|
+
: emptyState({
|
|
1155
|
+
heading: 'No config yet',
|
|
1156
|
+
why: 'Bugpatrol reads the config in .bugpatrol/ to know what to test.',
|
|
1157
|
+
command: 'bugpatrol init',
|
|
1158
|
+
compact: true,
|
|
1159
|
+
}),
|
|
1160
|
+
]);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function apiCoverageBlock(api, surface) {
|
|
1164
|
+
if (!api)
|
|
1165
|
+
return emptyState({
|
|
1166
|
+
heading: 'No sweep yet',
|
|
1167
|
+
why: 'The sweep sends each case of each endpoint and checks the answer against the spec.',
|
|
1168
|
+
command: cli('sweep', surface),
|
|
1169
|
+
compact: true,
|
|
1170
|
+
});
|
|
1171
|
+
return el('button', { type: 'button', class: 'surface-numbers', onclick: () => go('coverage') }, [
|
|
1172
|
+
el('div', { class: 'surface-line' }, [
|
|
1173
|
+
el('strong', { class: 'big', text: percent(api.tested, api.endpoints) }),
|
|
1174
|
+
el('span', { text: `of the endpoints tested: ${number(api.tested)} of ${number(api.endpoints)}` }),
|
|
1175
|
+
]),
|
|
1176
|
+
caseBar(api.cases),
|
|
1177
|
+
caseLegend(api.cases),
|
|
1178
|
+
api.remaining
|
|
1179
|
+
? el('small', { class: 'muted', text: `${number(api.remaining)} cases wait for the next sweep.` })
|
|
1180
|
+
: null,
|
|
1181
|
+
]);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function screenCoverageBlock(data, summary, surface) {
|
|
1185
|
+
const screens = summary.coverage.screens;
|
|
1186
|
+
if (!screens?.found)
|
|
1187
|
+
return emptyState({
|
|
1188
|
+
heading: 'No screens yet',
|
|
1189
|
+
why: surface.configs[0]
|
|
1190
|
+
? `${fileName(surface.configs[0])} tests the ${surfaceName(surface).toLowerCase()}. The explorer finds its screens when it runs.`
|
|
1191
|
+
: 'The explorer finds the screens when it runs.',
|
|
1192
|
+
command: cli('explore', surface),
|
|
1193
|
+
compact: true,
|
|
1194
|
+
});
|
|
1195
|
+
return el('div', {}, [
|
|
1196
|
+
el('div', { class: 'surface-line' }, [
|
|
1197
|
+
el('strong', { class: 'big', text: number(screens.found) }),
|
|
1198
|
+
el('span', {
|
|
1199
|
+
text: `screens found · ${number(screens.replayable)} with a routine · ${number(screens.withIssues)} with open issues`,
|
|
1200
|
+
}),
|
|
1201
|
+
]),
|
|
773
1202
|
el(
|
|
774
1203
|
'div',
|
|
775
1204
|
{ class: 'coverage-row' },
|
|
776
1205
|
data.screens.map((screen) =>
|
|
777
1206
|
el('button', { class: 'coverage-item', onclick: () => openScreen(screen.id) }, [
|
|
778
|
-
|
|
1207
|
+
captureFile(screen.lastCapture, screen.name),
|
|
779
1208
|
el('span', { text: screen.name }),
|
|
780
1209
|
]),
|
|
781
1210
|
),
|
|
782
1211
|
),
|
|
783
1212
|
]);
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function renderRecentSessions(data) {
|
|
1216
|
+
const next = data.patrol?.nextAt
|
|
1217
|
+
? `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`
|
|
1218
|
+
: data.live
|
|
1219
|
+
? ''
|
|
1220
|
+
: 'Quiet now';
|
|
1221
|
+
return el('section', { class: 'card panel' }, [
|
|
1222
|
+
title('Recent sessions', next),
|
|
1223
|
+
data.recentSessions.length
|
|
1224
|
+
? el(
|
|
1225
|
+
'div',
|
|
1226
|
+
{ class: 'stack' },
|
|
1227
|
+
data.recentSessions.map((session) =>
|
|
1228
|
+
el('button', { class: 'list-row', onclick: () => openSession(session.id) }, [
|
|
1229
|
+
roleIcon(session.role),
|
|
1230
|
+
el('span', { class: 'grow' }, [
|
|
1231
|
+
el('strong', {
|
|
1232
|
+
class: 'truncate',
|
|
1233
|
+
text: firstSentence(session.summary) || `${capital(session.role)} session`,
|
|
1234
|
+
}),
|
|
1235
|
+
el('small', {
|
|
1236
|
+
class: 'muted',
|
|
1237
|
+
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1238
|
+
}),
|
|
1239
|
+
]),
|
|
1240
|
+
el('span', { class: `badge ${SESSION_BADGE[session.status] ?? 'info'}`, text: session.status }),
|
|
1241
|
+
]),
|
|
1242
|
+
),
|
|
1243
|
+
)
|
|
1244
|
+
: emptyState({
|
|
1245
|
+
heading: 'No sessions yet',
|
|
1246
|
+
why: 'Each run of an agent is a session.',
|
|
1247
|
+
command: cli('patrol --once'),
|
|
1248
|
+
compact: true,
|
|
1249
|
+
}),
|
|
789
1250
|
]);
|
|
790
1251
|
}
|
|
791
1252
|
|
|
1253
|
+
const SESSION_BADGE = { running: 'warn', failed: 'fail', finished: 'info' };
|
|
1254
|
+
|
|
1255
|
+
/** Where an issue shows: its screen, or for an API issue, its request. */
|
|
1256
|
+
function issueWhere(issue) {
|
|
1257
|
+
const request = issue.evidence?.capture?.kind === 'http' ? issue.evidence.capture.exchange?.request : undefined;
|
|
1258
|
+
return !issue.screenId && request ? `${request.method} ${request.path}` : screenName(issue.screenId);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
792
1261
|
function screenName(id) {
|
|
793
1262
|
return (
|
|
794
1263
|
state.appmap?.screens.find((screen) => screen.id === id)?.name ||
|
|
@@ -809,6 +1278,25 @@ async function openIssue(id) {
|
|
|
809
1278
|
}
|
|
810
1279
|
|
|
811
1280
|
function renderIssues() {
|
|
1281
|
+
if (!state.issues.length) {
|
|
1282
|
+
const { waiting, verdict } = state.summary;
|
|
1283
|
+
return el('section', { class: 'card panel' }, [
|
|
1284
|
+
title('Issues'),
|
|
1285
|
+
emptyState({
|
|
1286
|
+
heading: 'No issues yet',
|
|
1287
|
+
why: [
|
|
1288
|
+
'An issue is a problem that the judge confirmed, with its evidence, its steps, and its fix.',
|
|
1289
|
+
waiting.total
|
|
1290
|
+
? `${number(waiting.total)} findings wait for the judge now. The judge files the real problems as issues.`
|
|
1291
|
+
: verdict.tone === 'info'
|
|
1292
|
+
? 'Bugpatrol did not test this project yet.'
|
|
1293
|
+
: 'The judge found no real problem yet.',
|
|
1294
|
+
],
|
|
1295
|
+
command: cli('patrol --once'),
|
|
1296
|
+
action: waiting.total ? { label: 'Show the findings', onclick: () => go('activity') } : undefined,
|
|
1297
|
+
}),
|
|
1298
|
+
]);
|
|
1299
|
+
}
|
|
812
1300
|
const filters = ['open', 'fixed', 'dismissed', 'all'];
|
|
813
1301
|
const filtered = visibleIssues();
|
|
814
1302
|
const list = el('div', { class: 'card panel' }, [
|
|
@@ -827,6 +1315,9 @@ function renderIssues() {
|
|
|
827
1315
|
}),
|
|
828
1316
|
),
|
|
829
1317
|
),
|
|
1318
|
+
filtered.length
|
|
1319
|
+
? null
|
|
1320
|
+
: el('p', { class: 'muted', text: `No issue is ${state.issueFilter}. Select a different filter.` }),
|
|
830
1321
|
...filtered.map((issue) =>
|
|
831
1322
|
el(
|
|
832
1323
|
'button',
|
|
@@ -838,7 +1329,7 @@ function renderIssues() {
|
|
|
838
1329
|
el('span', { class: 'row-meta' }, [
|
|
839
1330
|
el('small', {
|
|
840
1331
|
class: 'muted',
|
|
841
|
-
text: `${
|
|
1332
|
+
text: `${issueWhere(issue)} · ${relativeTime(issue.lastSeenAt)}`,
|
|
842
1333
|
}),
|
|
843
1334
|
...issueChips(issue),
|
|
844
1335
|
]),
|
|
@@ -866,13 +1357,22 @@ function renderIssueDetail() {
|
|
|
866
1357
|
if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
|
|
867
1358
|
const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
|
|
868
1359
|
const evidence = issue.evidence ?? {};
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1360
|
+
const capture = evidence.capture;
|
|
1361
|
+
const shots =
|
|
1362
|
+
capture?.kind === 'http'
|
|
1363
|
+
? [
|
|
1364
|
+
el('figure', { class: 'http-evidence' }, [
|
|
1365
|
+
captureView(capture, 'HTTP exchange'),
|
|
1366
|
+
el('figcaption', { text: 'HTTP exchange' }),
|
|
1367
|
+
]),
|
|
1368
|
+
]
|
|
1369
|
+
: [
|
|
1370
|
+
['Screenshot', capture?.screenshot],
|
|
1371
|
+
['Baseline', capture?.baseline],
|
|
1372
|
+
['Diff', capture?.diff],
|
|
1373
|
+
]
|
|
1374
|
+
.filter(([, path]) => path)
|
|
1375
|
+
.map(([label, path]) => el('figure', {}, [captureFile(path, label), el('figcaption', { text: label })]));
|
|
876
1376
|
const steps = evidence.steps ?? detail.routine?.steps ?? [];
|
|
877
1377
|
return el('article', { class: 'card panel issue-detail' }, [
|
|
878
1378
|
el('div', { class: 'detail-heading' }, [el('h1', { text: issue.title }), severityChip(issue.severity)]),
|
|
@@ -892,7 +1392,7 @@ function renderIssueDetail() {
|
|
|
892
1392
|
: null,
|
|
893
1393
|
el('div', { class: 'kv' }, [
|
|
894
1394
|
el('span', { text: issueStatus(issue, fix) }),
|
|
895
|
-
el('span', { text:
|
|
1395
|
+
el('span', { text: issueWhere(issue) }),
|
|
896
1396
|
el('span', { text: `×${issue.occurrences}` }),
|
|
897
1397
|
el('span', { text: `First ${relativeTime(issue.firstSeenAt)}` }),
|
|
898
1398
|
el('span', { text: `Last ${relativeTime(issue.lastSeenAt)}` }),
|
|
@@ -985,6 +1485,7 @@ function firstSentence(text) {
|
|
|
985
1485
|
function issueStatus(issue, fix) {
|
|
986
1486
|
if (issue.status === 'dismissed') return issue.closedBy ? `Dismissed by ${issue.closedBy.by}` : 'Dismissed';
|
|
987
1487
|
if (issue.status === 'fixed') return issue.closedBy ? `Fixed: ${issue.closedBy.reason}` : 'Fixed';
|
|
1488
|
+
if (issue.status === 'waits-for-deploy') return 'Merged, waits for a deploy';
|
|
988
1489
|
if (fix?.status === 'rejected' || issue.fixRejected) return 'Fix rejected by the team';
|
|
989
1490
|
if (issue.status === 'new' && issue.regression) return 'Regression';
|
|
990
1491
|
if (fix?.status === 'verified') return 'Fix verified in the app';
|
|
@@ -1006,6 +1507,7 @@ function stepWords(step) {
|
|
|
1006
1507
|
if (step.kind === 'open') return `Open ${step.url}`;
|
|
1007
1508
|
if (step.kind === 'wait') return `Wait ${step.ms} ms`;
|
|
1008
1509
|
if (step.kind === 'window') return `Switch to window "${step.match}"`;
|
|
1510
|
+
if (step.kind === 'upload') return `Upload a sample ${step.file} file to "${target}"`;
|
|
1009
1511
|
return step.kind === 'back' ? 'Go back' : step.kind;
|
|
1010
1512
|
}
|
|
1011
1513
|
|
|
@@ -1134,16 +1636,20 @@ function lazyDiff(path) {
|
|
|
1134
1636
|
}
|
|
1135
1637
|
|
|
1136
1638
|
function retestBlock(retest, issue) {
|
|
1137
|
-
const
|
|
1639
|
+
const capture = issue.evidence?.capture;
|
|
1640
|
+
const before = retest.before ?? capture?.screenshot;
|
|
1641
|
+
// An API issue has no before file: the reported exchange is the before.
|
|
1642
|
+
const beforeView = (path) =>
|
|
1643
|
+
!path && capture?.kind === 'http' ? captureView(capture, 'Before') : captureFile(path, 'Before');
|
|
1138
1644
|
const pair = (shot) =>
|
|
1139
1645
|
el('div', { class: 'retest-shot-pair' }, [
|
|
1140
|
-
retest.
|
|
1646
|
+
retest.captures?.length ? el('h4', { text: screenName(shot.screenId) }) : null,
|
|
1141
1647
|
shot.reached === false ? el('span', { class: 'muted', text: 'Not reached' }) : null,
|
|
1142
1648
|
shot.note ? el('p', { class: 'muted', text: shot.note }) : null,
|
|
1143
1649
|
el('div', { class: 'retest-shots' }, [
|
|
1144
|
-
el('figure', {}, [
|
|
1650
|
+
el('figure', {}, [beforeView(shot.before), el('figcaption', { text: 'Before' })]),
|
|
1145
1651
|
el('figure', {}, [
|
|
1146
|
-
|
|
1652
|
+
captureFile(shot.after, 'After (with the fix)'),
|
|
1147
1653
|
el('figcaption', { text: 'After (with the fix)' }),
|
|
1148
1654
|
]),
|
|
1149
1655
|
]),
|
|
@@ -1151,8 +1657,8 @@ function retestBlock(retest, issue) {
|
|
|
1151
1657
|
return el('div', { class: 'retest-block' }, [
|
|
1152
1658
|
el('h3', { text: `Retest ${retest.attempt}: ${retest.outcome}` }),
|
|
1153
1659
|
el('p', { text: retest.reason }),
|
|
1154
|
-
retest.
|
|
1155
|
-
? retest.
|
|
1660
|
+
retest.captures?.length
|
|
1661
|
+
? retest.captures.map((shot) => pair(shot))
|
|
1156
1662
|
: pair({ before, after: retest.after, note: retest.note }),
|
|
1157
1663
|
]);
|
|
1158
1664
|
}
|
|
@@ -1205,9 +1711,9 @@ function inlineMarkdown(line) {
|
|
|
1205
1711
|
return parts;
|
|
1206
1712
|
}
|
|
1207
1713
|
|
|
1208
|
-
function
|
|
1209
|
-
|
|
1210
|
-
title(
|
|
1714
|
+
function sessionList(heading) {
|
|
1715
|
+
return el('section', { class: 'card panel' }, [
|
|
1716
|
+
title(heading, `${state.sessions.length}`),
|
|
1211
1717
|
...state.sessions.map((session) =>
|
|
1212
1718
|
el(
|
|
1213
1719
|
'button',
|
|
@@ -1224,59 +1730,228 @@ function renderActivity() {
|
|
|
1224
1730
|
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
1225
1731
|
el('small', {
|
|
1226
1732
|
class: 'muted',
|
|
1227
|
-
text:
|
|
1228
|
-
`${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)} · ${session.steps} steps · ` +
|
|
1229
|
-
`${session.tokens ? `${tokens(session.tokens)} · ` : ''}API ${money(session.costUsd)} · ` +
|
|
1230
|
-
`${session.screensFound.length} screens · ${session.issues.length} issues`,
|
|
1733
|
+
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1231
1734
|
}),
|
|
1232
1735
|
]),
|
|
1736
|
+
session.status === 'finished'
|
|
1737
|
+
? null
|
|
1738
|
+
: el('span', { class: `badge ${SESSION_BADGE[session.status]}`, text: session.status }),
|
|
1233
1739
|
],
|
|
1234
1740
|
),
|
|
1235
1741
|
),
|
|
1236
1742
|
]);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function noSessions(page) {
|
|
1746
|
+
return el('section', { class: 'card panel' }, [
|
|
1747
|
+
title(page),
|
|
1748
|
+
emptyState({
|
|
1749
|
+
heading: 'No sessions yet',
|
|
1750
|
+
why: [
|
|
1751
|
+
page === 'Flow'
|
|
1752
|
+
? 'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline.'
|
|
1753
|
+
: 'Activity shows each agent session: what it tested, what it found, and what the judge decided.',
|
|
1754
|
+
'No agent ran in this project yet.',
|
|
1755
|
+
],
|
|
1756
|
+
command: cli('patrol --once'),
|
|
1757
|
+
}),
|
|
1758
|
+
]);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function renderActivity() {
|
|
1762
|
+
if (!state.sessions.length) return noSessions('Activity');
|
|
1237
1763
|
const detail = state.sessionDetail;
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1764
|
+
return el('div', { class: 'master-detail' }, [
|
|
1765
|
+
sessionList('Sessions'),
|
|
1766
|
+
detail ? renderSessionDetail(detail) : el('section', { class: 'card panel empty', text: 'Select a session.' }),
|
|
1767
|
+
]);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// Most findings of a sweep wait for the judge, so that state is quiet text and the others are badges.
|
|
1771
|
+
const CANDIDATE_STATES = {
|
|
1772
|
+
waiting: ['', 'Waits for the judge'],
|
|
1773
|
+
filed: ['fail', 'Filed as an issue'],
|
|
1774
|
+
dismissed: ['info', 'Dismissed'],
|
|
1775
|
+
ignored: ['info', 'Not sent to the judge'],
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
/** One session: its numbers first, then its findings by failure, then its events. */
|
|
1779
|
+
function renderSessionDetail(detail) {
|
|
1780
|
+
const { session, report, candidates, events } = detail;
|
|
1781
|
+
const { findings, sweep } = report;
|
|
1782
|
+
const cases = sweep ? sweep.pass + sweep.fail + sweep.blocked : 0;
|
|
1783
|
+
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
1784
|
+
const agentEvents = events.some((event) => event.kind !== 'candidate');
|
|
1785
|
+
const timeline = el(
|
|
1786
|
+
'div',
|
|
1787
|
+
{ class: 'event-list' },
|
|
1788
|
+
events.filter((event) => state.showThoughts || isFeedEvent(event)).map(renderEventRow),
|
|
1789
|
+
);
|
|
1790
|
+
return el('section', { class: 'card panel session-detail' }, [
|
|
1791
|
+
title(
|
|
1792
|
+
firstSentence(session.summary) || `${capital(session.role)} session`,
|
|
1793
|
+
`${capital(session.role)} · ${formatTime(session.startedAt)}`,
|
|
1794
|
+
),
|
|
1795
|
+
statBand([
|
|
1796
|
+
{ label: 'Duration', value: session.status === 'running' ? 'Running' : formatDuration(report.durationMs) },
|
|
1797
|
+
sweep
|
|
1798
|
+
? {
|
|
1799
|
+
label: 'Cases in this session',
|
|
1800
|
+
value: number(cases),
|
|
1801
|
+
note: `${number(sweep.endpoints)} endpoints tested · ${number(sweep.blocked)} blocked`,
|
|
1802
|
+
}
|
|
1803
|
+
: null,
|
|
1804
|
+
sweep
|
|
1805
|
+
? {
|
|
1806
|
+
label: 'Pass · fail · blocked',
|
|
1807
|
+
value: `${number(sweep.pass)} · ${number(sweep.fail)} · ${number(sweep.blocked)}`,
|
|
1808
|
+
bar: caseBar(sweep),
|
|
1809
|
+
}
|
|
1810
|
+
: null,
|
|
1811
|
+
report.steps || !sweep
|
|
1812
|
+
? { label: 'Steps', value: number(report.steps), note: `${report.screens.length} screens found` }
|
|
1813
|
+
: null,
|
|
1814
|
+
{
|
|
1815
|
+
label: 'Findings',
|
|
1816
|
+
value: number(findings.total),
|
|
1817
|
+
note: sweep
|
|
1818
|
+
? `${number(findings.repeat)} failed two times`
|
|
1819
|
+
: `${number(findings.filed)} filed as ${findings.filed === 1 ? 'an issue' : 'issues'}`,
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
label: 'Wait for the judge',
|
|
1823
|
+
value: number(findings.waiting),
|
|
1824
|
+
note: `${number(findings.filed)} filed · ${number(findings.dismissed)} dismissed`,
|
|
1825
|
+
tone: findings.waiting ? 'warn' : undefined,
|
|
1826
|
+
},
|
|
1827
|
+
session.tokens
|
|
1828
|
+
? {
|
|
1829
|
+
label: 'Tokens',
|
|
1830
|
+
value: count(session.tokens.input + session.tokens.output),
|
|
1831
|
+
note: `API ${money(session.costUsd)}`,
|
|
1832
|
+
}
|
|
1833
|
+
: null,
|
|
1834
|
+
]),
|
|
1835
|
+
findings.waiting
|
|
1836
|
+
? callout(
|
|
1837
|
+
`The judge did not read ${number(findings.waiting)} findings of this session yet. Run the judge:`,
|
|
1838
|
+
cli('patrol --once'),
|
|
1839
|
+
)
|
|
1840
|
+
: null,
|
|
1841
|
+
report.groups.length
|
|
1842
|
+
? el('section', { class: 'subsection' }, [
|
|
1843
|
+
title(
|
|
1844
|
+
'Findings',
|
|
1845
|
+
`${number(findings.total)} in ${report.groups.length} ${report.groups.length === 1 ? 'group' : 'groups'}`,
|
|
1846
|
+
),
|
|
1847
|
+
...report.groups.map((group) => findingGroup(group, byId)),
|
|
1848
|
+
])
|
|
1849
|
+
: el('p', { class: 'muted', text: 'This session reported no finding.' }),
|
|
1850
|
+
countChips('Findings by case', report.byCase),
|
|
1851
|
+
countChips('Findings by check', report.byCheck),
|
|
1852
|
+
countChips(
|
|
1853
|
+
'Findings by screen',
|
|
1854
|
+
report.byScreen.map(({ label, count: n }) => ({ label: screenName(label), count: n })),
|
|
1855
|
+
),
|
|
1856
|
+
// A sweep uses no model, so it has no usage to show.
|
|
1857
|
+
session.tokensByModel
|
|
1858
|
+
? el('section', { class: 'subsection' }, [
|
|
1859
|
+
title('Usage'),
|
|
1860
|
+
el(
|
|
1861
|
+
'div',
|
|
1862
|
+
{ class: 'muted session-usage' },
|
|
1863
|
+
Object.entries(session.tokensByModel).map(([model, usage]) =>
|
|
1864
|
+
el('div', { title: tokenDetail(usage), text: `${model}: ${tokens(usage)}` }),
|
|
1865
|
+
),
|
|
1866
|
+
),
|
|
1867
|
+
])
|
|
1868
|
+
: null,
|
|
1869
|
+
// A sweep has no actions: its events repeat its findings, so they stay one click away.
|
|
1870
|
+
agentEvents
|
|
1871
|
+
? el('section', { class: 'subsection' }, [
|
|
1872
|
+
title(
|
|
1873
|
+
'Timeline',
|
|
1874
|
+
state.showThoughts
|
|
1875
|
+
? `${events.length} events`
|
|
1876
|
+
: `${events.filter(isFeedEvent).length} of ${events.length} events`,
|
|
1877
|
+
),
|
|
1878
|
+
// The heading shows the first sentence. A longer summary shows here in full.
|
|
1879
|
+
session.summary && firstSentence(session.summary) !== session.summary.trim()
|
|
1880
|
+
? el('div', { class: 'session-summary' }, [markdown(session.summary)])
|
|
1881
|
+
: null,
|
|
1882
|
+
el('label', { class: 'toggle' }, [
|
|
1883
|
+
el('input', {
|
|
1884
|
+
type: 'checkbox',
|
|
1885
|
+
...(state.showThoughts ? { checked: '' } : {}),
|
|
1886
|
+
onchange: (event) => {
|
|
1887
|
+
state.showThoughts = event.target.checked;
|
|
1888
|
+
render();
|
|
1889
|
+
},
|
|
1274
1890
|
}),
|
|
1891
|
+
el('span', { text: 'Show agent reasoning' }),
|
|
1275
1892
|
]),
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1893
|
+
timeline,
|
|
1894
|
+
])
|
|
1895
|
+
: el('details', { class: 'subsection raw-events' }, [
|
|
1896
|
+
el('summary', { text: `All events (${events.length})` }),
|
|
1897
|
+
timeline,
|
|
1898
|
+
]),
|
|
1899
|
+
]);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function countChips(heading, counts) {
|
|
1903
|
+
if (!counts.length) return null;
|
|
1904
|
+
return el('section', { class: 'subsection' }, [
|
|
1905
|
+
title(heading),
|
|
1906
|
+
el(
|
|
1907
|
+
'div',
|
|
1908
|
+
{ class: 'chips' },
|
|
1909
|
+
counts.map(({ label, count: n }) =>
|
|
1910
|
+
el('span', { class: 'chip' }, [el('span', { text: label }), el('strong', { text: number(n) })]),
|
|
1911
|
+
),
|
|
1912
|
+
),
|
|
1913
|
+
]);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/** A group of findings with the same failure. Each finding opens to its evidence. */
|
|
1917
|
+
function findingGroup(group, byId) {
|
|
1918
|
+
const members = group.candidateIds.map((id) => byId.get(id)).filter(Boolean);
|
|
1919
|
+
return el('details', { class: 'finding-group' }, [
|
|
1920
|
+
el('summary', {}, [
|
|
1921
|
+
el('span', { class: 'group-label', text: capital(group.label) }),
|
|
1922
|
+
...group.assertions.map((id) => el('code', { class: 'assertion', text: id })),
|
|
1923
|
+
el('span', {
|
|
1924
|
+
class: 'muted group-repeat',
|
|
1925
|
+
text: group.repeat && group.repeat < group.count ? `${group.repeat} of ${group.count} failed two times` : '',
|
|
1926
|
+
}),
|
|
1927
|
+
el('strong', { class: 'group-count', text: number(group.count) }),
|
|
1928
|
+
]),
|
|
1929
|
+
...members.map((candidate) => {
|
|
1930
|
+
const [badge, words] = CANDIDATE_STATES[candidate.state] ?? ['info', candidate.state];
|
|
1931
|
+
const at = candidate.source === 'sweep' ? candidate.summary.indexOf(': ') : -1;
|
|
1932
|
+
const capture = candidate.evidence?.capture;
|
|
1933
|
+
return el('details', { class: 'candidate-row' }, [
|
|
1934
|
+
el('summary', {}, [
|
|
1935
|
+
el('span', { class: 'grow truncate', text: at > 0 ? candidate.summary.slice(0, at) : candidate.summary }),
|
|
1936
|
+
candidate.state === 'filed' && candidate.issueId
|
|
1937
|
+
? el('button', {
|
|
1938
|
+
type: 'button',
|
|
1939
|
+
class: `badge ${badge}`,
|
|
1940
|
+
text: words,
|
|
1941
|
+
onclick: (event) => {
|
|
1942
|
+
event.preventDefault();
|
|
1943
|
+
openIssue(candidate.issueId);
|
|
1944
|
+
},
|
|
1945
|
+
})
|
|
1946
|
+
: el('span', { class: badge ? `badge ${badge}` : 'muted state-words', text: words }),
|
|
1947
|
+
]),
|
|
1948
|
+
candidate.route?.reason ? el('p', { class: 'muted', text: candidate.route.reason }) : null,
|
|
1949
|
+
capture?.kind === 'http'
|
|
1950
|
+
? el('pre', { class: 'http', text: candidate.text })
|
|
1951
|
+
: captureFile(capture?.screenshot, candidate.summary, 'candidate-shot'),
|
|
1952
|
+
]);
|
|
1953
|
+
}),
|
|
1954
|
+
]);
|
|
1280
1955
|
}
|
|
1281
1956
|
|
|
1282
1957
|
function duration(session) {
|
|
@@ -1297,7 +1972,7 @@ function renderEventRow(event) {
|
|
|
1297
1972
|
text: tokens(event.tokens, ''),
|
|
1298
1973
|
})
|
|
1299
1974
|
: null,
|
|
1300
|
-
event.
|
|
1975
|
+
event.capture ? captureFile(event.capture, event.summary, 'event-thumb') : null,
|
|
1301
1976
|
event.input !== undefined || event.output !== undefined
|
|
1302
1977
|
? el('details', {}, [
|
|
1303
1978
|
el('summary', { text: 'Tool data' }),
|
|
@@ -1311,52 +1986,17 @@ function renderEventRow(event) {
|
|
|
1311
1986
|
// failed requests the app reported, and the backend logs that followed, merged
|
|
1312
1987
|
// in time order. A log close behind a failure is marked correlated.
|
|
1313
1988
|
function renderFlow() {
|
|
1314
|
-
|
|
1315
|
-
title('Flow'),
|
|
1316
|
-
...state.sessions.map((session) =>
|
|
1317
|
-
el(
|
|
1318
|
-
'button',
|
|
1319
|
-
{
|
|
1320
|
-
class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
|
|
1321
|
-
onclick: async () => {
|
|
1322
|
-
state.selectedSessionId = session.id;
|
|
1323
|
-
await refresh();
|
|
1324
|
-
},
|
|
1325
|
-
},
|
|
1326
|
-
[
|
|
1327
|
-
roleIcon(session.role),
|
|
1328
|
-
el('span', { class: 'grow' }, [
|
|
1329
|
-
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
1330
|
-
el('small', {
|
|
1331
|
-
class: 'muted',
|
|
1332
|
-
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1333
|
-
}),
|
|
1334
|
-
]),
|
|
1335
|
-
],
|
|
1336
|
-
),
|
|
1337
|
-
),
|
|
1338
|
-
]);
|
|
1989
|
+
if (!state.sessions.length) return noSessions('Flow');
|
|
1339
1990
|
const flow = state.flow;
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
list,
|
|
1343
|
-
el('section', {
|
|
1344
|
-
class: 'card panel empty',
|
|
1345
|
-
text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
|
|
1346
|
-
}),
|
|
1347
|
-
]);
|
|
1348
|
-
}
|
|
1991
|
+
const list = sessionList('Sessions');
|
|
1992
|
+
if (!flow) return el('div', { class: 'master-detail' }, [list, el('section', { class: 'card panel' }, [noFlow()])]);
|
|
1349
1993
|
const sources = flow.sources.length
|
|
1350
1994
|
? flow.sources.map((source) =>
|
|
1351
1995
|
el('div', {
|
|
1352
1996
|
text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
|
|
1353
1997
|
}),
|
|
1354
1998
|
)
|
|
1355
|
-
: [
|
|
1356
|
-
el('div', {
|
|
1357
|
-
text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
|
|
1358
|
-
}),
|
|
1359
|
-
];
|
|
1999
|
+
: [el('div', { text: 'The config had no log source for this session, so the flow has no backend logs.' })];
|
|
1360
2000
|
return el('div', { class: 'master-detail' }, [
|
|
1361
2001
|
list,
|
|
1362
2002
|
el('section', { class: 'card panel' }, [
|
|
@@ -1369,13 +2009,56 @@ function renderFlow() {
|
|
|
1369
2009
|
]);
|
|
1370
2010
|
}
|
|
1371
2011
|
|
|
2012
|
+
const LOGS_EXAMPLE = `logs:
|
|
2013
|
+
- name: api
|
|
2014
|
+
kind: file
|
|
2015
|
+
path: logs/api.log`;
|
|
2016
|
+
|
|
2017
|
+
/** Why the selected session has no flow, and the next step. */
|
|
2018
|
+
function noFlow() {
|
|
2019
|
+
const summary = state.summary;
|
|
2020
|
+
const session = state.sessionDetail?.session;
|
|
2021
|
+
const report = state.sessionDetail?.report;
|
|
2022
|
+
const what =
|
|
2023
|
+
'Flow puts the actions of a session, the requests that failed, and the backend logs in one timeline, so you can see which log line follows which request.';
|
|
2024
|
+
if (session?.status === 'running')
|
|
2025
|
+
return emptyState({
|
|
2026
|
+
heading: 'The flow shows when the session ends',
|
|
2027
|
+
why: [what, 'This session runs now. Bugpatrol writes its flow at the end.'],
|
|
2028
|
+
});
|
|
2029
|
+
if (!summary.logs.length)
|
|
2030
|
+
return emptyState({
|
|
2031
|
+
heading: 'No backend logs',
|
|
2032
|
+
why: [
|
|
2033
|
+
what,
|
|
2034
|
+
`${summary.config ? fileName(summary.config) : 'The config'} has no logs: source, so Bugpatrol reads no backend logs. Add one, then run a patrol:`,
|
|
2035
|
+
],
|
|
2036
|
+
command: LOGS_EXAMPLE,
|
|
2037
|
+
docs: `${DOCS}/dashboard.md#flow`,
|
|
2038
|
+
});
|
|
2039
|
+
if (report?.sweep && !report.steps)
|
|
2040
|
+
return emptyState({
|
|
2041
|
+
heading: 'A sweep has no flow',
|
|
2042
|
+
why: [
|
|
2043
|
+
what,
|
|
2044
|
+
'This session is a sweep from bugpatrol sweep, which reads no logs. A patrol runs the sweep and reads the logs: sources too.',
|
|
2045
|
+
],
|
|
2046
|
+
command: cli('patrol --once'),
|
|
2047
|
+
});
|
|
2048
|
+
return emptyState({
|
|
2049
|
+
heading: 'No flow for this session',
|
|
2050
|
+
why: [what, 'This session ran before the config had a logs: source. The next patrol writes a flow.'],
|
|
2051
|
+
command: cli('patrol --once'),
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
|
|
1372
2055
|
function renderFlowRow(entry) {
|
|
1373
2056
|
return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
|
|
1374
2057
|
el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
|
|
1375
2058
|
el('span', { class: 'flow-kind', text: entry.kind }),
|
|
1376
2059
|
el('span', { class: 'grow truncate', text: entry.summary }),
|
|
1377
2060
|
entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
|
|
1378
|
-
entry.
|
|
2061
|
+
entry.capture ? captureFile(entry.capture, entry.summary, 'event-thumb') : null,
|
|
1379
2062
|
]);
|
|
1380
2063
|
}
|
|
1381
2064
|
|
|
@@ -1385,9 +2068,37 @@ async function openScreen(id) {
|
|
|
1385
2068
|
await refresh();
|
|
1386
2069
|
}
|
|
1387
2070
|
|
|
2071
|
+
/** Screens belong to a client app. For an API, the page says so and points to Coverage. */
|
|
2072
|
+
function noScreens() {
|
|
2073
|
+
const clients = surfacesOf('client');
|
|
2074
|
+
const apis = surfacesOf('api');
|
|
2075
|
+
const coverage = { label: 'Open Coverage', onclick: () => go('coverage') };
|
|
2076
|
+
if (!clients.length)
|
|
2077
|
+
return emptyState({
|
|
2078
|
+
heading: 'This project tests an API',
|
|
2079
|
+
why: [
|
|
2080
|
+
'Screens shows the screens that the explorer finds in a client app, and how they connect.',
|
|
2081
|
+
`${apis[0]?.configs[0] ? fileName(apis[0].configs[0]) : 'The config'} tests an API, which has no screens. Coverage shows its endpoints and cases.`,
|
|
2082
|
+
'A monorepo can test a client app too: add a config for it in .bugpatrol/.',
|
|
2083
|
+
],
|
|
2084
|
+
action: coverage,
|
|
2085
|
+
});
|
|
2086
|
+
const client = clients[0];
|
|
2087
|
+
return emptyState({
|
|
2088
|
+
heading: 'No screens yet',
|
|
2089
|
+
why: [
|
|
2090
|
+
'Screens shows the screens that the explorer finds in a client app, and how they connect.',
|
|
2091
|
+
`${client.configs[0] ? fileName(client.configs[0]) : 'This project'} tests the ${surfaceName(client).toLowerCase()}. The explorer did not find a screen yet.`,
|
|
2092
|
+
apis.length ? 'This project also tests an API. Coverage shows its endpoints.' : '',
|
|
2093
|
+
].filter(Boolean),
|
|
2094
|
+
command: cli('explore', client),
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1388
2098
|
function renderScreens() {
|
|
1389
2099
|
const graphScreens = state.appmap?.screens ?? [];
|
|
1390
2100
|
const screens = graphScreens.filter((screen) => !screen.virtual);
|
|
2101
|
+
if (!screens.length) return el('section', { class: 'card panel' }, [title('Screens'), noScreens()]);
|
|
1391
2102
|
const edges = state.appmap?.edges ?? [];
|
|
1392
2103
|
const selected = screens.find((screen) => screen.id === state.selectedScreenId);
|
|
1393
2104
|
if (selected) {
|
|
@@ -1405,7 +2116,7 @@ function renderScreens() {
|
|
|
1405
2116
|
},
|
|
1406
2117
|
}),
|
|
1407
2118
|
title(selected.name, `${selected.visits} visits · ${relativeTime(selected.lastSeenAt)}`),
|
|
1408
|
-
|
|
2119
|
+
captureFile(selected.lastCapture, selected.name, 'screen-large'),
|
|
1409
2120
|
el('p', { text: selected.description }),
|
|
1410
2121
|
title('Routine'),
|
|
1411
2122
|
el('p', {
|
|
@@ -1456,38 +2167,48 @@ function renderScreens() {
|
|
|
1456
2167
|
}),
|
|
1457
2168
|
),
|
|
1458
2169
|
);
|
|
2170
|
+
const apis = surfacesOf('api');
|
|
1459
2171
|
return el('div', {}, [
|
|
1460
2172
|
title('Screens', `${screens.length} found`),
|
|
2173
|
+
apis.length
|
|
2174
|
+
? el('p', { class: 'muted surface-note' }, [
|
|
2175
|
+
`This project also tests an API${apis[0].configs[0] ? ` (${fileName(apis[0].configs[0])})` : ''}. `,
|
|
2176
|
+
el('button', {
|
|
2177
|
+
type: 'button',
|
|
2178
|
+
class: 'link',
|
|
2179
|
+
text: 'Coverage shows its endpoints.',
|
|
2180
|
+
onclick: () => go('coverage'),
|
|
2181
|
+
}),
|
|
2182
|
+
])
|
|
2183
|
+
: null,
|
|
1461
2184
|
toggle,
|
|
1462
|
-
|
|
1463
|
-
?
|
|
1464
|
-
:
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
'
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
[
|
|
1477
|
-
|
|
1478
|
-
el('
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
]),
|
|
1487
|
-
],
|
|
1488
|
-
),
|
|
2185
|
+
screenMode === 'graph'
|
|
2186
|
+
? renderScreenGraph(graphScreens, edges)
|
|
2187
|
+
: el(
|
|
2188
|
+
'div',
|
|
2189
|
+
{ class: 'screen-grid' },
|
|
2190
|
+
screens.map((screen) =>
|
|
2191
|
+
el(
|
|
2192
|
+
'button',
|
|
2193
|
+
{
|
|
2194
|
+
class: 'card screen-card',
|
|
2195
|
+
onclick: () => openScreen(screen.id),
|
|
2196
|
+
},
|
|
2197
|
+
[
|
|
2198
|
+
captureFile(screen.lastCapture, screen.name),
|
|
2199
|
+
el('div', { class: 'screen-copy' }, [
|
|
2200
|
+
el('strong', { text: screen.name }),
|
|
2201
|
+
el('p', { class: 'truncate muted', text: screen.description }),
|
|
2202
|
+
el('small', {
|
|
2203
|
+
class: 'muted',
|
|
2204
|
+
text: `${relativeTime(screen.lastSeenAt)} · ${screen.visits} visits`,
|
|
2205
|
+
}),
|
|
2206
|
+
screen.openIssues ? el('span', { class: 'issue-count', text: `${screen.openIssues} open` }) : null,
|
|
2207
|
+
]),
|
|
2208
|
+
],
|
|
1489
2209
|
),
|
|
1490
2210
|
),
|
|
2211
|
+
),
|
|
1491
2212
|
]);
|
|
1492
2213
|
}
|
|
1493
2214
|
|
|
@@ -1585,14 +2306,14 @@ function renderScreenGraph(screens, allEdges) {
|
|
|
1585
2306
|
},
|
|
1586
2307
|
});
|
|
1587
2308
|
card.append(svgEl('rect', { x: node.x, y: node.y, width: node.w, height: node.h, rx: 9, class: 'graph-card' }));
|
|
1588
|
-
if (screen.
|
|
2309
|
+
if (screen.lastCapture)
|
|
1589
2310
|
card.append(
|
|
1590
2311
|
svgEl('image', {
|
|
1591
2312
|
x: node.x + 5,
|
|
1592
2313
|
y: node.y + 5,
|
|
1593
2314
|
width: node.w - 10,
|
|
1594
2315
|
height: 104,
|
|
1595
|
-
href: artifact(screen.
|
|
2316
|
+
href: artifact(screen.lastCapture),
|
|
1596
2317
|
preserveAspectRatio: 'xMidYMin slice',
|
|
1597
2318
|
}),
|
|
1598
2319
|
);
|
|
@@ -1764,11 +2485,21 @@ async function openReview(number) {
|
|
|
1764
2485
|
}
|
|
1765
2486
|
|
|
1766
2487
|
function renderReviews() {
|
|
2488
|
+
if (!state.reviews.length)
|
|
2489
|
+
return el('section', { class: 'card panel' }, [
|
|
2490
|
+
title('Pull request reviews'),
|
|
2491
|
+
emptyState({
|
|
2492
|
+
heading: 'No pull request reviews yet',
|
|
2493
|
+
why: [
|
|
2494
|
+
'A review tests one pull request in the running app. It checks each claim of the pull request and lists the problems that the pull request adds.',
|
|
2495
|
+
'Bugpatrol did not review a pull request in this project yet. Give the number of a pull request:',
|
|
2496
|
+
],
|
|
2497
|
+
command: cli('review <pr>'),
|
|
2498
|
+
docs: `${DOCS}/github.md`,
|
|
2499
|
+
}),
|
|
2500
|
+
]);
|
|
1767
2501
|
const list = el('div', { class: 'card panel' }, [
|
|
1768
2502
|
title('Pull request reviews', `${state.reviews.length}`),
|
|
1769
|
-
state.reviews.length
|
|
1770
|
-
? null
|
|
1771
|
-
: el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
|
|
1772
2503
|
...state.reviews.map((review) =>
|
|
1773
2504
|
el(
|
|
1774
2505
|
'button',
|
|
@@ -1959,7 +2690,9 @@ function buildEvidence(label, replay, recordings) {
|
|
|
1959
2690
|
el(
|
|
1960
2691
|
'div',
|
|
1961
2692
|
{ class: 'shot-strip' },
|
|
1962
|
-
shots.map((path, index) =>
|
|
2693
|
+
shots.map((path, index) =>
|
|
2694
|
+
captureFile(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`),
|
|
2695
|
+
),
|
|
1963
2696
|
);
|
|
1964
2697
|
return el('figure', { class: 'build-evidence' }, [
|
|
1965
2698
|
pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
|
|
@@ -1978,7 +2711,10 @@ function recordingView(rec) {
|
|
|
1978
2711
|
el('small', { class: 'muted', text: name }),
|
|
1979
2712
|
]);
|
|
1980
2713
|
if (rec.kind === 'image')
|
|
1981
|
-
return el('div', { class: 'recording' }, [
|
|
2714
|
+
return el('div', { class: 'recording' }, [
|
|
2715
|
+
captureFile(rec.path, name),
|
|
2716
|
+
el('small', { class: 'muted', text: name }),
|
|
2717
|
+
]);
|
|
1982
2718
|
return castPlayer(rec.path);
|
|
1983
2719
|
}
|
|
1984
2720
|
|
|
@@ -2025,8 +2761,11 @@ function findingBlock(finding) {
|
|
|
2025
2761
|
el('p', { text: finding.reason }),
|
|
2026
2762
|
finding.head || finding.base
|
|
2027
2763
|
? el('div', { class: 'retest-shots' }, [
|
|
2028
|
-
el('figure', {}, [
|
|
2029
|
-
el('figure', {}, [
|
|
2764
|
+
el('figure', {}, [captureFile(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
|
|
2765
|
+
el('figure', {}, [
|
|
2766
|
+
captureFile(finding.head, 'This pull request'),
|
|
2767
|
+
el('figcaption', { text: 'This pull request' }),
|
|
2768
|
+
]),
|
|
2030
2769
|
])
|
|
2031
2770
|
: null,
|
|
2032
2771
|
finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
|
|
@@ -2051,6 +2790,18 @@ function renderMemory() {
|
|
|
2051
2790
|
el('span', { class: 'badge info', text: lesson.scope ?? 'app' }),
|
|
2052
2791
|
el('span', { class: 'muted', text: `${lesson.source} · ×${lesson.hits} · ${relativeTime(lesson.lastSeenAt)}` }),
|
|
2053
2792
|
]);
|
|
2793
|
+
if (!lessons.length)
|
|
2794
|
+
return el('section', { class: 'card panel' }, [
|
|
2795
|
+
title('Memory'),
|
|
2796
|
+
emptyState({
|
|
2797
|
+
heading: 'No lessons yet',
|
|
2798
|
+
why: [
|
|
2799
|
+
'A lesson is a fact about the app that an agent keeps for its next sessions, for example how to sign in.',
|
|
2800
|
+
'The agents save lessons when they learn something. You can also add one:',
|
|
2801
|
+
],
|
|
2802
|
+
command: cli('memory add --role explorer "text"'),
|
|
2803
|
+
}),
|
|
2804
|
+
]);
|
|
2054
2805
|
return el('section', { class: 'card panel' }, [
|
|
2055
2806
|
title('Memory'),
|
|
2056
2807
|
el('p', { class: 'muted', text: 'Use bugpatrol memory to edit lessons.' }),
|
|
@@ -2067,6 +2818,311 @@ function renderMemory() {
|
|
|
2067
2818
|
]);
|
|
2068
2819
|
}
|
|
2069
2820
|
|
|
2821
|
+
const CASE_WORDS = {
|
|
2822
|
+
ok: ['2xx', 'The request as the spec describes it'],
|
|
2823
|
+
'no-auth': ['no-auth', 'The request without credentials'],
|
|
2824
|
+
'invalid-input': ['invalid-input', 'A body or a parameter that the schema rejects'],
|
|
2825
|
+
'missing-id': ['missing-id', 'An id that does not exist'],
|
|
2826
|
+
'other-user': ['other-user', 'The data of one user, read by a second user'],
|
|
2827
|
+
'repeat-delete': ['repeat-delete', 'The same DELETE two times'],
|
|
2828
|
+
};
|
|
2829
|
+
const CELL_WORDS = {
|
|
2830
|
+
pass: 'Pass',
|
|
2831
|
+
fail: 'Fail',
|
|
2832
|
+
blocked: 'Blocked: the sweep could not run the case',
|
|
2833
|
+
skip: 'Skip: the request policy refuses the case',
|
|
2834
|
+
untested: 'Untested: the case waits in the sweep cursor',
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
function caseHeader(id) {
|
|
2838
|
+
if (id.startsWith('declared:')) {
|
|
2839
|
+
const code = id.slice('declared:'.length);
|
|
2840
|
+
return [code, `A declared error status. The cell shows the case that got ${code}.`];
|
|
2841
|
+
}
|
|
2842
|
+
return CASE_WORDS[id] ?? [id, id];
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
const LEGEND = { pass: '200', fail: '500', blocked: 'blocked', skip: 'skip', untested: 'untested' };
|
|
2846
|
+
const CELL_BADGE = { pass: 'pass', fail: 'fail', blocked: 'warn' };
|
|
2847
|
+
|
|
2848
|
+
/** The exchange behind one cell, as the server formats it, in a panel at the side of the matrix. */
|
|
2849
|
+
function renderCellExchange(key, cell) {
|
|
2850
|
+
const space = key.lastIndexOf(' ');
|
|
2851
|
+
const label = `${key.slice(0, space)} · ${caseHeader(key.slice(space + 1))[0]}`;
|
|
2852
|
+
const reason = cell.state === 'fail' ? `Failed checks: ${cell.reason}` : cell.reason;
|
|
2853
|
+
return el('aside', { class: 'drawer card', 'aria-label': 'The exchange of the cell' }, [
|
|
2854
|
+
el('div', { class: 'section-title' }, [
|
|
2855
|
+
el('h2', { class: 'mono', text: label }),
|
|
2856
|
+
el('span', { class: `badge ${CELL_BADGE[cell.state] ?? 'info'}`, text: cell.state }),
|
|
2857
|
+
el('button', {
|
|
2858
|
+
type: 'button',
|
|
2859
|
+
class: 'close',
|
|
2860
|
+
'aria-label': 'Close the exchange',
|
|
2861
|
+
text: '×',
|
|
2862
|
+
onclick: () => {
|
|
2863
|
+
state.selectedCell = null;
|
|
2864
|
+
render();
|
|
2865
|
+
},
|
|
2866
|
+
}),
|
|
2867
|
+
]),
|
|
2868
|
+
reason ? el('p', { class: 'muted', text: reason }) : null,
|
|
2869
|
+
captureFile(cell.exchange, label),
|
|
2870
|
+
]);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/** Coverage for each surface of the project: the endpoints of an API, the screens of a client app. */
|
|
2874
|
+
function renderCoverage() {
|
|
2875
|
+
const summary = state.summary;
|
|
2876
|
+
if (!summary.surfaces.length)
|
|
2877
|
+
return el('section', { class: 'card panel' }, [
|
|
2878
|
+
title('Coverage'),
|
|
2879
|
+
emptyState({
|
|
2880
|
+
heading: 'No config yet',
|
|
2881
|
+
why: 'Coverage shows what Bugpatrol tested: the screens of a client app and the endpoints of an API.',
|
|
2882
|
+
command: 'bugpatrol init',
|
|
2883
|
+
}),
|
|
2884
|
+
]);
|
|
2885
|
+
return el(
|
|
2886
|
+
'div',
|
|
2887
|
+
{ class: 'stack-gap' },
|
|
2888
|
+
summary.surfaces.map((surface) =>
|
|
2889
|
+
surface.kind === 'api' ? renderApiCoverage(surface) : renderScreenCoverage(surface),
|
|
2890
|
+
),
|
|
2891
|
+
);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function surfaceTitle(surface, aside) {
|
|
2895
|
+
return el('div', { class: 'section-title' }, [
|
|
2896
|
+
el('h2', {}, [
|
|
2897
|
+
surfaceName(surface),
|
|
2898
|
+
surface.configs[0]
|
|
2899
|
+
? el('span', { class: 'muted mono config-name', text: ` ${fileName(surface.configs[0])}` })
|
|
2900
|
+
: null,
|
|
2901
|
+
]),
|
|
2902
|
+
aside ? el('span', { class: 'muted', text: aside }) : null,
|
|
2903
|
+
]);
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function renderScreenCoverage(surface) {
|
|
2907
|
+
const totals = state.summary.coverage.screens;
|
|
2908
|
+
const screens = (state.appmap?.screens ?? []).filter((screen) => !screen.virtual);
|
|
2909
|
+
if (!totals?.found)
|
|
2910
|
+
return el('section', { class: 'card panel' }, [
|
|
2911
|
+
surfaceTitle(surface),
|
|
2912
|
+
screenCoverageBlock(null, state.summary, surface),
|
|
2913
|
+
]);
|
|
2914
|
+
const open = state.issues.filter((issue) => !['fixed', 'dismissed'].includes(issue.status));
|
|
2915
|
+
const rows = screens
|
|
2916
|
+
.map((screen) => ({ screen, issues: open.filter((issue) => issue.screenId === screen.id).length }))
|
|
2917
|
+
.sort((a, b) => b.issues - a.issues || a.screen.name.localeCompare(b.screen.name));
|
|
2918
|
+
return el('section', { class: 'card panel' }, [
|
|
2919
|
+
surfaceTitle(surface, 'Screens that the explorer found'),
|
|
2920
|
+
statBand([
|
|
2921
|
+
{ label: 'Screens found', value: number(totals.found) },
|
|
2922
|
+
{ label: 'With a routine', value: number(totals.replayable), note: 'Bugpatrol can reach them again' },
|
|
2923
|
+
{
|
|
2924
|
+
label: 'With open issues',
|
|
2925
|
+
value: number(totals.withIssues),
|
|
2926
|
+
tone: totals.withIssues ? 'fail' : undefined,
|
|
2927
|
+
note: `${number(open.length)} open issues in all`,
|
|
2928
|
+
},
|
|
2929
|
+
]),
|
|
2930
|
+
el('table', { class: 'usage-table screen-table' }, [
|
|
2931
|
+
el(
|
|
2932
|
+
'tr',
|
|
2933
|
+
{},
|
|
2934
|
+
['Screen', 'Visits', 'Routine', 'Open issues', 'Last seen'].map((text) => el('th', { text })),
|
|
2935
|
+
),
|
|
2936
|
+
...rows.map(({ screen, issues }) =>
|
|
2937
|
+
el('tr', {}, [
|
|
2938
|
+
el('td', {}, [
|
|
2939
|
+
el('button', { type: 'button', class: 'link', text: screen.name, onclick: () => openScreen(screen.id) }),
|
|
2940
|
+
]),
|
|
2941
|
+
el('td', { text: number(screen.visits) }),
|
|
2942
|
+
el('td', { text: screen.routineId ? 'Yes' : 'No' }),
|
|
2943
|
+
el('td', {}, [
|
|
2944
|
+
issues
|
|
2945
|
+
? el('span', { class: 'issue-count', text: `${issues} open` })
|
|
2946
|
+
: el('span', { class: 'muted', text: '0' }),
|
|
2947
|
+
]),
|
|
2948
|
+
el('td', { text: relativeTime(screen.lastSeenAt) }),
|
|
2949
|
+
]),
|
|
2950
|
+
),
|
|
2951
|
+
]),
|
|
2952
|
+
]);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
/**
|
|
2956
|
+
* The rank of an endpoint: 0 with a failed case, 1 with a passed case, 2 with
|
|
2957
|
+
* a case that waits in the cursor, and 3 when the sweep skipped or blocked all of its cases.
|
|
2958
|
+
*/
|
|
2959
|
+
function endpointRank(endpoint, cases, cells) {
|
|
2960
|
+
let rank = 3;
|
|
2961
|
+
for (const id of cases) {
|
|
2962
|
+
const cell = cells[`${endpoint} ${id}`]?.state;
|
|
2963
|
+
if (cell === 'fail') return 0;
|
|
2964
|
+
if (cell === 'pass') rank = 1;
|
|
2965
|
+
if (cell === 'untested') rank = Math.min(rank, 2);
|
|
2966
|
+
}
|
|
2967
|
+
return rank;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
function renderApiCoverage(surface) {
|
|
2971
|
+
const totals = state.summary.coverage.api;
|
|
2972
|
+
const data = state.coverage;
|
|
2973
|
+
if (!totals || !data)
|
|
2974
|
+
return el('section', { class: 'card panel' }, [surfaceTitle(surface), apiCoverageBlock(undefined, surface)]);
|
|
2975
|
+
const { cases, cells } = data;
|
|
2976
|
+
const own = CELL_STATES.reduce((sum, name) => sum + totals.cases[name], 0);
|
|
2977
|
+
const ranks = new Map(data.endpoints.map((endpoint) => [endpoint, endpointRank(endpoint, cases, cells)]));
|
|
2978
|
+
const endpoints = [...data.endpoints].sort((a, b) => ranks.get(a) - ranks.get(b));
|
|
2979
|
+
const filter = state.coverageFilter;
|
|
2980
|
+
const tbody = el('tbody', {
|
|
2981
|
+
onclick: (event) => {
|
|
2982
|
+
const button = event.target.closest('button[data-key]');
|
|
2983
|
+
if (!button) return;
|
|
2984
|
+
const key = button.dataset.key;
|
|
2985
|
+
state.selectedCell = state.selectedCell === key ? null : key;
|
|
2986
|
+
render();
|
|
2987
|
+
},
|
|
2988
|
+
});
|
|
2989
|
+
const shown = el('span', { class: 'muted' });
|
|
2990
|
+
const fill = () => {
|
|
2991
|
+
const query = filter.query.trim().toLowerCase();
|
|
2992
|
+
const rows = endpoints.filter(
|
|
2993
|
+
(endpoint) =>
|
|
2994
|
+
(!filter.failedOnly || ranks.get(endpoint) === 0) &&
|
|
2995
|
+
(!filter.hideSkipped || ranks.get(endpoint) < 3) &&
|
|
2996
|
+
(!query || endpoint.toLowerCase().includes(query)),
|
|
2997
|
+
);
|
|
2998
|
+
tbody.replaceChildren(...rows.map((endpoint) => matrixRow(endpoint, cases, cells)));
|
|
2999
|
+
shown.textContent = `${number(rows.length)} of ${number(endpoints.length)} endpoints`;
|
|
3000
|
+
};
|
|
3001
|
+
fill();
|
|
3002
|
+
const declared = cases.filter((id) => id.startsWith('declared:')).length;
|
|
3003
|
+
const table = el('table', { class: `matrix${filter.hideSkipped ? ' hide-skipped' : ''}` }, [
|
|
3004
|
+
el('thead', {}, [
|
|
3005
|
+
// The declared columns repeat a case that got that status, so they get their own group.
|
|
3006
|
+
declared
|
|
3007
|
+
? el('tr', { class: 'matrix-groups' }, [
|
|
3008
|
+
el('th', {}),
|
|
3009
|
+
el('th', {}),
|
|
3010
|
+
el('th', { colspan: declared, class: 'declared', text: 'Declared error statuses' }),
|
|
3011
|
+
cases.length > declared + 1 ? el('th', { colspan: cases.length - declared - 1, text: 'Cases' }) : null,
|
|
3012
|
+
])
|
|
3013
|
+
: null,
|
|
3014
|
+
el('tr', {}, [
|
|
3015
|
+
el('th', { text: 'Endpoint' }),
|
|
3016
|
+
...cases.map((id) => {
|
|
3017
|
+
const [label, tip] = caseHeader(id);
|
|
3018
|
+
return el('th', { class: id.startsWith('declared:') ? 'declared' : '', title: tip, text: label });
|
|
3019
|
+
}),
|
|
3020
|
+
]),
|
|
3021
|
+
]),
|
|
3022
|
+
tbody,
|
|
3023
|
+
]);
|
|
3024
|
+
const toggle = (key, label) =>
|
|
3025
|
+
el('label', { class: 'toggle' }, [
|
|
3026
|
+
el('input', {
|
|
3027
|
+
type: 'checkbox',
|
|
3028
|
+
...(filter[key] ? { checked: '' } : {}),
|
|
3029
|
+
onchange: (event) => {
|
|
3030
|
+
filter[key] = event.target.checked;
|
|
3031
|
+
table.classList.toggle('hide-skipped', filter.hideSkipped);
|
|
3032
|
+
fill();
|
|
3033
|
+
},
|
|
3034
|
+
}),
|
|
3035
|
+
el('span', { text: label }),
|
|
3036
|
+
]);
|
|
3037
|
+
const selected = state.selectedCell && cells[state.selectedCell];
|
|
3038
|
+
return el('section', { class: 'card panel' }, [
|
|
3039
|
+
surfaceTitle(surface, 'Endpoint × case, from the sweep'),
|
|
3040
|
+
statBand([
|
|
3041
|
+
{
|
|
3042
|
+
label: 'Endpoints tested',
|
|
3043
|
+
value: percent(totals.tested, totals.endpoints),
|
|
3044
|
+
note: `${number(totals.tested)} of ${number(totals.endpoints)}`,
|
|
3045
|
+
},
|
|
3046
|
+
{
|
|
3047
|
+
label: 'All cases',
|
|
3048
|
+
value: number(own),
|
|
3049
|
+
bar: caseBar(totals.cases),
|
|
3050
|
+
note: `${number(totals.cases.pass)} pass · ${number(totals.cases.fail)} fail`,
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
label: 'Failed',
|
|
3054
|
+
value: number(totals.cases.fail),
|
|
3055
|
+
tone: totals.cases.fail ? 'fail' : undefined,
|
|
3056
|
+
note: `${percent(totals.cases.fail, totals.cases.pass + totals.cases.fail)} of the cases that ran`,
|
|
3057
|
+
},
|
|
3058
|
+
{
|
|
3059
|
+
label: 'Blocked · skipped',
|
|
3060
|
+
value: `${number(totals.cases.blocked)} · ${number(totals.cases.skip)}`,
|
|
3061
|
+
note: 'The sweep could not run them, or the policy refuses them',
|
|
3062
|
+
},
|
|
3063
|
+
{
|
|
3064
|
+
label: 'Wait in the cursor',
|
|
3065
|
+
value: number(totals.remaining),
|
|
3066
|
+
note: totals.remaining ? 'The next sweep runs them first' : 'This pass is complete',
|
|
3067
|
+
},
|
|
3068
|
+
]),
|
|
3069
|
+
el('div', { class: 'matrix-tools' }, [
|
|
3070
|
+
el('input', {
|
|
3071
|
+
type: 'search',
|
|
3072
|
+
class: 'search',
|
|
3073
|
+
placeholder: 'Search the paths',
|
|
3074
|
+
'aria-label': 'Search the endpoint paths',
|
|
3075
|
+
value: filter.query,
|
|
3076
|
+
oninput: (event) => {
|
|
3077
|
+
filter.query = event.target.value;
|
|
3078
|
+
fill();
|
|
3079
|
+
},
|
|
3080
|
+
}),
|
|
3081
|
+
toggle('failedOnly', 'Failed only'),
|
|
3082
|
+
toggle('hideSkipped', 'Hide skipped and blocked'),
|
|
3083
|
+
shown,
|
|
3084
|
+
el('div', { class: 'matrix-legend' }, [
|
|
3085
|
+
...Object.entries(LEGEND).map(([name, sample]) =>
|
|
3086
|
+
el('span', {}, [el('span', { class: `matrix-cell ${name}`, text: sample }), name]),
|
|
3087
|
+
),
|
|
3088
|
+
el('span', { text: 'Blank: the sweep makes no such case.' }),
|
|
3089
|
+
]),
|
|
3090
|
+
]),
|
|
3091
|
+
el('div', { class: 'matrix-scroll', 'data-keep-scroll': '' }, [table]),
|
|
3092
|
+
selected ? renderCellExchange(state.selectedCell, selected) : null,
|
|
3093
|
+
]);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
function matrixRow(endpoint, cases, cells) {
|
|
3097
|
+
const space = endpoint.indexOf(' ');
|
|
3098
|
+
return el('tr', {}, [
|
|
3099
|
+
el('th', { scope: 'row' }, [
|
|
3100
|
+
el('span', { class: 'method', text: endpoint.slice(0, space) }),
|
|
3101
|
+
el('code', { text: endpoint.slice(space + 1) }),
|
|
3102
|
+
]),
|
|
3103
|
+
...cases.map((id) => {
|
|
3104
|
+
const key = `${endpoint} ${id}`;
|
|
3105
|
+
const cell = cells[key];
|
|
3106
|
+
if (!cell) return el('td', { class: 'matrix-none', title: 'The sweep makes no such case for this endpoint' });
|
|
3107
|
+
const tip = [CELL_WORDS[cell.state], cell.reason, cell.at ? relativeTime(cell.at) : ''].filter(Boolean);
|
|
3108
|
+
const props = {
|
|
3109
|
+
class: `matrix-cell ${cell.state}${state.selectedCell === key ? ' active' : ''}`,
|
|
3110
|
+
text: cell.status ?? cell.state,
|
|
3111
|
+
};
|
|
3112
|
+
return el('td', { class: `cell-${cell.state}` }, [
|
|
3113
|
+
cell.exchange
|
|
3114
|
+
? el('button', {
|
|
3115
|
+
...props,
|
|
3116
|
+
type: 'button',
|
|
3117
|
+
'data-key': key,
|
|
3118
|
+
title: [...tip, 'Click to open the exchange'].join('\n'),
|
|
3119
|
+
})
|
|
3120
|
+
: el('span', { ...props, title: tip.join('\n') }),
|
|
3121
|
+
]);
|
|
3122
|
+
}),
|
|
3123
|
+
]);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
2070
3126
|
// ---------------------------------------------------------------- lightbox
|
|
2071
3127
|
|
|
2072
3128
|
const lightbox = document.getElementById('lightbox');
|
|
@@ -2092,6 +3148,7 @@ tabs.addEventListener('click', async (event) => {
|
|
|
2092
3148
|
if (!target) return;
|
|
2093
3149
|
state.view = target.dataset.view;
|
|
2094
3150
|
if (state.view === 'screens') state.selectedScreenId = null;
|
|
3151
|
+
if (state.view === 'coverage') state.selectedCell = null;
|
|
2095
3152
|
await refresh();
|
|
2096
3153
|
});
|
|
2097
3154
|
|