Files
Georgii Butakov 1568f1e68e feat(progress): report completion by executed templates
The -stats percentage was requests sent against a total estimated
before the scan: TotalRequests of each template after clustering, times
the number of targets. The estimate leaves out workflows, whose requests
are added while they run, counts a self-contained template once per
target although it runs once, and doesn't follow stop-at-first-match or
a template that runs once per origin. A scan could end at 108% or stop
at 25%.

The engine now counts tasks: one per template, cluster or workflow run
on a target, and one per self-contained template. The total is known
when the scan starts. A task is done when it runs, when -resume skips
it, when its host is unresponsive, or when it repeats its origin.

progress.TaskTracker is an optional interface that StatsTicker
implements, so Progress and lib.StatsWriter don't change. The stats
line shows Tasks: done/total and takes the percentage from it, and -sj
and the metrics endpoint report tasks, totalTasks and the same percent.
2026-10-10 12:00:33 +00:00

196 lines
4.2 KiB
Go

package progress
import (
"testing"
"time"
"github.com/projectdiscovery/clistats"
"github.com/stretchr/testify/require"
)
func TestStatsTickerInit_registersPeriodicStatsOnly_whenIntervalIsPositive(t *testing.T) {
tests := []struct {
name string
tickDuration time.Duration
wantPeriodicStats bool
}{
{
name: "interval is disabled",
tickDuration: -1,
wantPeriodicStats: false,
},
{
name: "interval is positive",
tickDuration: time.Second,
wantPeriodicStats: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Given
stats := &statsClientSpy{}
ticker := &StatsTicker{
active: true,
stats: stats,
tickDuration: test.tickDuration,
}
// When
ticker.Init(1, 1, 1)
// Then
require.True(t, stats.started)
require.Equal(t, test.wantPeriodicStats, stats.periodicStatsRequested)
})
}
}
func TestMetricsMapPercent(t *testing.T) {
tests := []struct {
name string
requests uint64
total uint64
tasks uint64
totalTasks uint64
wantPercent string
}{
{
name: "total is known",
requests: 50,
total: 200,
wantPercent: "25",
},
{
name: "total is unknown",
requests: 80,
total: 0,
wantPercent: "0",
},
{
name: "tasks are counted",
requests: 50,
total: 200,
tasks: 3,
totalTasks: 4,
wantPercent: "75",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
// Given
stats := &statsClientSpy{
counters: map[string]uint64{
"requests": test.requests,
"total": test.total,
"tasks": test.tasks,
"totalTasks": test.totalTasks,
},
statics: map[string]interface{}{"startedAt": time.Now().Add(-time.Second)},
}
// When
results := metricsMap(stats)
// Then
require.Equal(t, test.wantPercent, results["percent"])
require.Equal(t, clistats.String(test.tasks), results["tasks"])
require.Equal(t, clistats.String(test.totalTasks), results["totalTasks"])
})
}
}
func TestPrintCallbackTasks(t *testing.T) {
tests := []struct {
name string
tasks uint64
totalTasks uint64
want string
}{
{
name: "tasks are not counted",
want: "| Requests: 10/40 (25%)",
},
{
name: "tasks are counted",
tasks: 1,
totalTasks: 2,
want: "| Requests: 10/40 | Tasks: 1/2 (50%)",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ticker := &StatsTicker{}
stats := &statsClientSpy{
counters: map[string]uint64{
"requests": 10,
"total": 40,
"tasks": test.tasks,
"totalTasks": test.totalTasks,
},
statics: map[string]interface{}{"startedAt": time.Now()},
}
out, ok := ticker.makePrintCallback()(stats).(string)
require.True(t, ok)
require.Contains(t, out, test.want)
})
}
}
func TestPrintCallbackZeroTotal(t *testing.T) {
ticker := &StatsTicker{}
stats := &statsClientSpy{
counters: map[string]uint64{"requests": 0, "total": 0},
statics: map[string]interface{}{"startedAt": time.Now()},
}
out, ok := ticker.makePrintCallback()(stats).(string)
require.True(t, ok)
require.Contains(t, out, "(0%)")
}
type statsClientSpy struct {
periodicStatsRequested bool
started bool
counters map[string]uint64
statics map[string]interface{}
}
func (s *statsClientSpy) Start() error {
s.started = true
return nil
}
func (s *statsClientSpy) Stop() error {
return nil
}
func (s *statsClientSpy) AddCounter(string, uint64) {}
func (s *statsClientSpy) GetCounter(key string) (uint64, bool) {
value, ok := s.counters[key]
return value, ok
}
func (s *statsClientSpy) IncrementCounter(string, int) {}
func (s *statsClientSpy) AddStatic(string, interface{}) {}
func (s *statsClientSpy) GetStatic(key string) (interface{}, bool) {
value, ok := s.statics[key]
return value, ok
}
func (s *statsClientSpy) AddDynamic(string, clistats.DynamicCallback) {}
func (s *statsClientSpy) GetDynamic(string) (clistats.DynamicCallback, bool) {
return nil, false
}
func (s *statsClientSpy) GetStatResponse(time.Duration, func(string, error) error) {
s.periodicStatsRequested = true
}