Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/hypofuzz/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 8 additions & 2 deletions src/hypofuzz/frontend/src/components/RangeSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand All @@ -89,7 +91,11 @@ export function RangeSlider({ min, max, value, onChange, step = 1 }: RangeSlider
return (
<div className="range-slider">
<div className="range-slider__container">
<div ref={sliderRef} className="range-slider__track" onClick={handleTrackClick}>
<div
ref={sliderRef}
className="range-slider__track"
onMouseDown={handleTrackMouseDown}
>
<div
className="range-slider__range"
style={{
Expand Down
7 changes: 0 additions & 7 deletions src/hypofuzz/frontend/src/components/graph/DataLines.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,6 @@ interface DataLinesProps {
navigate: (path: string) => void
}

interface LineData {
pathData: string
color: string
url: string | null
key: string
}

export function DataLines({
lines,
viewportXScale,
Expand Down
158 changes: 140 additions & 18 deletions src/hypofuzz/frontend/src/pages/Workers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,72 @@ 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 {
Expand Down Expand Up @@ -163,6 +214,8 @@ export function WorkersPage() {
const navigate = useNavigate()
const { showTooltip, hideTooltip, moveTooltip } = useTooltip()
const [expandedWorkers, setExpandedWorkers] = useState<Set<string>>(new Set())
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(TIME_PERIODS[0]) // Default to "Latest"
const [userRange, setUserRange] = useState<[number, number] | null>(null)

const workerUuids = OrderedSet(
Array.from(tests.values())
Expand Down Expand Up @@ -230,14 +283,65 @@ export function WorkersPage() {
})

workers.sortKey(worker => worker.segments[0].start)

const [visibleRange, setVisibleRange] = useState<[number, number]>(
niceDefaultRange(minTimestamp, maxTimestamp),
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 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
Expand Down Expand Up @@ -287,12 +391,30 @@ export function WorkersPage() {
</div>
<div className="workers">
<div className="workers__controls">
<div className="workers__durations">
{TIME_PERIODS.map((period, index) => {
const available = index <= firstLargerPeriod
return (
<div
key={index}
className={`workers__durations__button ${
selectedPeriod.label === period.label
? "workers__durations__button--active"
: ""
} ${!available ? "workers__durations__button--disabled" : ""}`}
onClick={() => available && setSelectedPeriod(period)}
>
{period.label}
</div>
)
})}
</div>
<RangeSlider
min={minTimestamp}
max={maxTimestamp}
min={sliderRange[0]}
max={sliderRange[1]}
value={visibleRange}
onChange={newRange => setVisibleRange(newRange)}
step={(maxTimestamp - minTimestamp) / 1000}
onChange={newRange => setUserRange(newRange)}
step={(sliderRange[1] - sliderRange[0]) / 1000}
/>
</div>
<div className="workers__timeline-header">
Expand Down
45 changes: 45 additions & 0 deletions src/hypofuzz/frontend/src/styles/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/hypofuzz/frontend/src/styles/theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
10 changes: 10 additions & 0 deletions tests/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
6 changes: 6 additions & 0 deletions visual_tests/test_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")