From 1afe2d9feb772dc74c430706c9b58dc9192023de Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sat, 26 Jul 2025 18:27:48 -0400 Subject: [PATCH 1/3] more specific skip message for derandomize=True --- src/hypofuzz/collection.py | 8 ++++++++ tests/test_collection.py | 10 ++++++++++ visual_tests/test_collection.py | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/src/hypofuzz/collection.py b/src/hypofuzz/collection.py index 5cdb90a7..885461b1 100644 --- a/src/hypofuzz/collection.py +++ b/src/hypofuzz/collection.py @@ -106,6 +106,14 @@ def pytest_collection_finish(self, session: pytest.Session) -> None: test_settings = getattr( item.obj, "_hypothesis_internal_use_settings", settings() ) + + # derandomize=True implies database=None, so this will be skipped by our + # differing_database check below anyway, but we can give a less confusing + # skip reason by checking for derandomize explicitly. + if test_settings.derandomize: + self._skip_because("sets_derandomize", item.nodeid) + continue + if (test_database := test_settings.database) != settings().database: self._skip_because( "differing_database", diff --git a/tests/test_collection.py b/tests/test_collection.py index 238eb323..42954712 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -274,3 +274,13 @@ def test_a(n): pass """ assert not collect_names(code) + + +def test_skips_derandomize(): + code = """ + @given(st.integers()) + @settings(derandomize=True) + def test_a(n): + pass + """ + assert not collect_names(code) diff --git a/visual_tests/test_collection.py b/visual_tests/test_collection.py index 8fdaf8c5..bea32a3b 100644 --- a/visual_tests/test_collection.py +++ b/visual_tests/test_collection.py @@ -39,4 +39,10 @@ def test_no_generate_phase(n): pass +@settings(derandomize=True) +@given(st.integers()) +def test_sets_derandomize(n): + pass + + # TODO: visual tests for _skip_because("error") and _skip_because("not_a_function") From c67389ddafc9b133acaa401b552d61f61d2b84a8 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sun, 27 Jul 2025 03:09:53 -0400 Subject: [PATCH 2/3] improve worker view ux --- .../frontend/src/components/RangeSlider.tsx | 10 +- .../src/components/graph/DataLines.tsx | 7 - src/hypofuzz/frontend/src/pages/Workers.tsx | 147 +++++++++++++++--- src/hypofuzz/frontend/src/styles/styles.scss | 45 ++++++ src/hypofuzz/frontend/src/styles/theme.scss | 2 + 5 files changed, 184 insertions(+), 27 deletions(-) diff --git a/src/hypofuzz/frontend/src/components/RangeSlider.tsx b/src/hypofuzz/frontend/src/components/RangeSlider.tsx index 0c65fb36..9222e2c0 100644 --- a/src/hypofuzz/frontend/src/components/RangeSlider.tsx +++ b/src/hypofuzz/frontend/src/components/RangeSlider.tsx @@ -60,7 +60,7 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider setDragging(null) } - const handleTrackClick = (event: React.MouseEvent) => { + const handleTrackMouseDown = (event: React.MouseEvent) => { if (dragging) return const newValue = getValueFromPosition(event.clientX) @@ -69,8 +69,10 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider if (minDistance < maxDistance) { onChange([Math.min(newValue, maxValue), maxValue]) + setDragging("min") } else { onChange([minValue, Math.max(newValue, minValue)]) + setDragging("max") } } @@ -89,7 +91,11 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider return (
-
+
void } -interface LineData { - pathData: string - color: string - url: string | null - key: string -} - export function DataLines({ lines, viewportXScale, diff --git a/src/hypofuzz/frontend/src/pages/Workers.tsx b/src/hypofuzz/frontend/src/pages/Workers.tsx index 4a6ae637..d00c5466 100644 --- a/src/hypofuzz/frontend/src/pages/Workers.tsx +++ b/src/hypofuzz/frontend/src/pages/Workers.tsx @@ -31,21 +31,68 @@ class Worker { } } +interface TimePeriod { + label: string + // duration in seconds + duration: number | null +} + +const TIME_PERIODS: TimePeriod[] = [ + { label: "Latest", duration: null }, + { label: "1 hour", duration: 1 * 60 * 60 }, + { label: "1 day", duration: 24 * 60 * 60 }, + { label: "7 days", duration: 7 * 24 * 60 * 60 }, + { label: "1 month", duration: 30 * 24 * 60 * 60 }, + { label: "3 months", duration: 90 * 24 * 60 * 60 }, +] + function formatTimestamp(timestamp: number): string { const date = new Date(timestamp * 1000) return date.toLocaleString() } -// 24 hours -const DEFAULT_RANGE_DURATION = 24 * 60 * 60 +// tolerance for a region, in seconds +const REGION_TOLERANCE = 5 * 60 + +function segmentRegions(segments: Segment[]): [number, number][] { + // returns a list of [start, end] regions, where a region is defined as the largest + // interval where there is no timestamp without an active segment. + // so in + // + // ``` + // [--] [-------] + // [---] [-] [------] + // [----] [--] + // ``` + // there are 3 regions. + + // We iterate over the egments in order of start time. We track the latest seen end time. + // If we ever see a segment with a later start time than the current end time, that means + // there must have been empty space between them, which marks a new region. + + // assert segments are sorted by segment.start + console.assert(segments.every((segment, index) => index === 0 || segment.start >= segments[index - 1].start)) + + if (segments.length == 0) { + return [] + } -function niceDefaultRange( - minTimestamp: number, - maxTimestamp: number, -): [number, number] { - // by default: show from maxTimestamp at the end, to DEFAULT_RANGE_DURATION seconds before - // that at the start. - return [Math.max(minTimestamp, maxTimestamp - DEFAULT_RANGE_DURATION), maxTimestamp] + let regions: [number, number][] = [] + let regionStart = segments[0].start + let latestEnd = segments[0].end + for (const segment of segments) { + if (segment.start > latestEnd + REGION_TOLERANCE) { + // this marks a new region + regions.push([regionStart, latestEnd]) + regionStart = segment.start + } + + latestEnd = Math.max(latestEnd, segment.end) + } + + // finalize the current region + regions.push([regionStart, latestEnd]) + return regions } function nodeColor(nodeid: string): string { @@ -163,6 +210,8 @@ export function WorkersPage() { const navigate = useNavigate() const { showTooltip, hideTooltip, moveTooltip } = useTooltip() const [expandedWorkers, setExpandedWorkers] = useState>(new Set()) + const [selectedPeriod, setSelectedPeriod] = useState(TIME_PERIODS[0]) // Default to "Latest" + const [userRange, setUserRange] = useState<[number, number] | null>(null) const workerUuids = OrderedSet( Array.from(tests.values()) @@ -230,14 +279,59 @@ export function WorkersPage() { }) workers.sortKey(worker => worker.segments[0].start) + const segments = workers.flatMap(worker => worker.segments).sortKey(segment => segment.start) + const regions = segmentRegions(segments) + + const span = maxTimestamp - minTimestamp + // find the first time period which is larger than the span of the workers. + // that time period is available, but anything after is not. + const firstLargerPeriod = TIME_PERIODS.findIndex(period => period.duration !== null && period.duration >= span) + + function getSliderRange(): [number, number] { + if (selectedPeriod.duration === null) { + const latestRegion = regions[regions.length - 1] + // the range is just the last region, unless there are no segments, in which case + // we use the min/max timestamp + return regions.length > 0 ? [latestRegion[0], latestRegion[1]] : [minTimestamp, maxTimestamp] + } - const [visibleRange, setVisibleRange] = useState<[number, number]>( - niceDefaultRange(minTimestamp, maxTimestamp), - ) + const range: [number, number] = [ + Math.max(minTimestamp, maxTimestamp - selectedPeriod.duration!), + maxTimestamp, + ] + + // trim the slider range to remove any time at the beginning or end when there + // are no active workers + let trimmedMin: number | null = null + let trimmedMax: number | null = null + for (const worker of workers) { + const visibleSegments = worker.visibleSegments(range) + if (visibleSegments.length === 0) { + continue + } + + if (trimmedMin === null || visibleSegments[0].start < trimmedMin) { + trimmedMin = visibleSegments[0].start + } + + if ( + trimmedMax === null || + visibleSegments[visibleSegments.length - 1].end > trimmedMax + ) { + trimmedMax = visibleSegments[visibleSegments.length - 1].end + } + } + + return [trimmedMin ?? range[0], trimmedMax ?? range[1]] + } + const sliderRange = getSliderRange() + const visibleRange = userRange ?? sliderRange useEffect(() => { - setVisibleRange(niceDefaultRange(minTimestamp, maxTimestamp)) - }, [minTimestamp, maxTimestamp]) + // reset the range when clicking on a period, even if it's the same period. This gives a + // nice "reset button" ux to users. + setUserRange(null) + }, [selectedPeriod]) const [visibleMin, visibleMax] = visibleRange const visibleDuration = visibleMax - visibleMin @@ -287,12 +381,29 @@ export function WorkersPage() {
+
+ {TIME_PERIODS.map((period, index) => { + const available = index <= firstLargerPeriod + return ( +
available && setSelectedPeriod(period)} + > + {period.label} +
+ )})} +
setVisibleRange(newRange)} - step={(maxTimestamp - minTimestamp) / 1000} + onChange={newRange => setUserRange(newRange)} + step={(sliderRange[1] - sliderRange[0]) / 1000} />
diff --git a/src/hypofuzz/frontend/src/styles/styles.scss b/src/hypofuzz/frontend/src/styles/styles.scss index 5d25569e..1ef1f2a2 100644 --- a/src/hypofuzz/frontend/src/styles/styles.scss +++ b/src/hypofuzz/frontend/src/styles/styles.scss @@ -1340,6 +1340,51 @@ pre code.hljs { } } + &__durations { + display: flex; + gap: 6px; + margin-bottom: 15px; + flex-wrap: wrap; + + &__button { + background: white; + color: #666; + + padding: 4px 7px; + border: 1px solid #d1d9e0; + border-radius: 4px; + + cursor: pointer; + font-size: 0.9rem; + user-select: none; + transition: all 0.05s ease; + + &:hover:not(&--disabled):not(&--active) { + background: #f8f9fa; + color: #495057; + } + + &--active { + background: $color-primary; + border-color: $color-primary; + color: white; + font-weight: 600; + + &:hover { + background: $color-primary-hover; + } + } + + &--disabled { + background: #f8f9fa; + border-color: #e9ecef; + color: #adb5bd; + cursor: not-allowed; + opacity: 0.6; + } + } + } + &__timeline-header { display: flex; justify-content: space-between; diff --git a/src/hypofuzz/frontend/src/styles/theme.scss b/src/hypofuzz/frontend/src/styles/theme.scss index cda40ab3..2302a9f1 100644 --- a/src/hypofuzz/frontend/src/styles/theme.scss +++ b/src/hypofuzz/frontend/src/styles/theme.scss @@ -3,6 +3,8 @@ $primary-s: 75%; $primary-l: 30%; $color-primary: hsl($primary-h, $primary-s, $primary-l); +$color-primary-hover: hsl($primary-h, $primary-s, 40%); + $secondary-h: 24; $secondary-s: 80%; $secondary-l: 60%; From 5623ab419e1297f622b82b65c184d17a3d502983 Mon Sep 17 00:00:00 2001 From: Liam DeVoe Date: Sun, 27 Jul 2025 03:13:40 -0400 Subject: [PATCH 3/3] format --- src/hypofuzz/frontend/src/pages/Workers.tsx | 49 +++++++++++++-------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/src/hypofuzz/frontend/src/pages/Workers.tsx b/src/hypofuzz/frontend/src/pages/Workers.tsx index d00c5466..31cd9a70 100644 --- a/src/hypofuzz/frontend/src/pages/Workers.tsx +++ b/src/hypofuzz/frontend/src/pages/Workers.tsx @@ -71,7 +71,11 @@ function segmentRegions(segments: Segment[]): [number, number][] { // there must have been empty space between them, which marks a new region. // assert segments are sorted by segment.start - console.assert(segments.every((segment, index) => index === 0 || segment.start >= segments[index - 1].start)) + console.assert( + segments.every( + (segment, index) => index === 0 || segment.start >= segments[index - 1].start, + ), + ) if (segments.length == 0) { return [] @@ -279,26 +283,32 @@ export function WorkersPage() { }) workers.sortKey(worker => worker.segments[0].start) - const segments = workers.flatMap(worker => worker.segments).sortKey(segment => segment.start) + const segments = workers + .flatMap(worker => worker.segments) + .sortKey(segment => segment.start) const regions = segmentRegions(segments) const span = maxTimestamp - minTimestamp // find the first time period which is larger than the span of the workers. // that time period is available, but anything after is not. - const firstLargerPeriod = TIME_PERIODS.findIndex(period => period.duration !== null && period.duration >= span) + const firstLargerPeriod = TIME_PERIODS.findIndex( + period => period.duration !== null && period.duration >= span, + ) function getSliderRange(): [number, number] { if (selectedPeriod.duration === null) { const latestRegion = regions[regions.length - 1] // the range is just the last region, unless there are no segments, in which case // we use the min/max timestamp - return regions.length > 0 ? [latestRegion[0], latestRegion[1]] : [minTimestamp, maxTimestamp] + return regions.length > 0 + ? [latestRegion[0], latestRegion[1]] + : [minTimestamp, maxTimestamp] } const range: [number, number] = [ - Math.max(minTimestamp, maxTimestamp - selectedPeriod.duration!), - maxTimestamp, - ] + Math.max(minTimestamp, maxTimestamp - selectedPeriod.duration!), + maxTimestamp, + ] // trim the slider range to remove any time at the beginning or end when there // are no active workers @@ -385,18 +395,19 @@ export function WorkersPage() { {TIME_PERIODS.map((period, index) => { const available = index <= firstLargerPeriod return ( -
available && setSelectedPeriod(period)} - > - {period.label} -
- )})} +
available && setSelectedPeriod(period)} + > + {period.label} +
+ ) + })}