## Summary Adds `tools/screencap`, a CLI for automated screenshots of the Fleet UI via headless Chrome (CDP). - **Built-in full workflow** captures all major pages and their modals/tabs (dashboard, hosts, queries, policies, software, controls, settings, account) in one command. - **Record custom workflows** in a visible browser — clicks, tabs, radios, checkboxes, and tooltip hovers are recorded automatically and saved as replayable JSON under `tools/screencap/workflows/`. - **Auth options** for SSO, email/password, or session cookie; the Chrome profile is persisted at `~/.fleet/screencap-profile/` so sessions survive across runs. - **Output** is written to `screenshots/<timestamp>-<workflow>-<host>/` as numbered PNGs, with multi-viewport pages split into separate images. See `tools/screencap/README.md` for full usage. ## Test plan - [ ] `cd tools/screencap && make build` produces the binary - [ ] `./screencap -sso https://<fleet-host>` captures the full built-in workflow - [ ] `./screencap -record demo https://<fleet-host>` records a workflow and saves it under `workflows/demo.json` - [ ] `./screencap -workflow demo https://<fleet-host>` replays the recorded workflow - [ ] `./screencap -list` shows saved workflows <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a screenshot automation tool that records and replays UI workflows, captures full-page and modal screenshots, restores scroll positions, and supports session persistence and multiple login methods. * **Chores** * Added a minimal build/clean workflow for the tool and updated ignore rules to avoid committing generated screenshots. * **Documentation / Templates** * Included example workflow templates demonstrating common navigation and interaction sequences. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/42437?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Luke Heath <luke@fleetdm.com>
805 lines
24 KiB
Go
805 lines
24 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/chromedp"
|
|
)
|
|
|
|
// modal describes a button to click to open a modal on a page,
|
|
// with optional tabs to click through inside the modal.
|
|
type modal struct {
|
|
name string
|
|
selector string
|
|
tabs []modalTab
|
|
}
|
|
|
|
type modalTab struct {
|
|
name string
|
|
selector string
|
|
}
|
|
|
|
type pageEntry struct {
|
|
path string
|
|
modals []modal
|
|
}
|
|
|
|
var addHostsModal = modal{
|
|
"add-hosts", `button[class*="add-hosts"]`,
|
|
[]modalTab{
|
|
{"macos", `[data-text="macOS"]`},
|
|
{"windows", `[data-text="Windows"]`},
|
|
{"linux", `[data-text="Linux"]`},
|
|
{"chromeos", `[data-text="ChromeOS"]`},
|
|
{"ios-ipados", `[data-text="iOS & iPadOS"]`},
|
|
{"android", `[data-text="Android"]`},
|
|
{"advanced", `[data-text="Advanced"]`},
|
|
},
|
|
}
|
|
|
|
// fullPages is the built-in "full" workflow.
|
|
var fullPages = []pageEntry{
|
|
{"/dashboard", []modal{addHostsModal}},
|
|
{"/dashboard/linux", nil},
|
|
{"/dashboard/mac", nil},
|
|
{"/dashboard/windows", nil},
|
|
{"/dashboard/chrome", nil},
|
|
{"/dashboard/ios", nil},
|
|
{"/dashboard/ipados", nil},
|
|
{"/hosts/manage", []modal{
|
|
addHostsModal,
|
|
{"edit-columns", `button[class*="edit-columns-button"]`, nil},
|
|
}},
|
|
{"/labels/manage", nil},
|
|
{"/queries/manage", []modal{
|
|
{"manage-automations", `button[class*="manage-automations"]`, nil},
|
|
}},
|
|
{"/queries/new", nil},
|
|
{"/policies/manage", []modal{
|
|
{"manage-automations", `button[class*="manage-automations"]`, nil},
|
|
}},
|
|
{"/policies/new", nil},
|
|
{"/software/titles", nil},
|
|
{"/software/os", nil},
|
|
{"/software/versions", nil},
|
|
{"/software/vulnerabilities", nil},
|
|
{"/controls", nil},
|
|
{"/controls/os-updates", nil},
|
|
{"/controls/os-settings", nil},
|
|
{"/controls/os-settings/custom-settings", nil},
|
|
{"/controls/os-settings/certificates", nil},
|
|
{"/controls/os-settings/disk-encryption", nil},
|
|
{"/controls/setup-experience", nil},
|
|
{"/controls/scripts", nil},
|
|
{"/controls/scripts/library", nil},
|
|
{"/controls/variables", []modal{
|
|
{"add-variable", `button[class*="add-secret"]`, nil},
|
|
}},
|
|
{"/settings/organization/info", nil},
|
|
{"/settings/organization/webaddress", nil},
|
|
{"/settings/organization/smtp", nil},
|
|
{"/settings/organization/agents", nil},
|
|
{"/settings/organization/statistics", nil},
|
|
{"/settings/organization/advanced", nil},
|
|
{"/settings/organization/fleet-desktop", nil},
|
|
{"/settings/users", []modal{
|
|
{"add-user", `.action-button__add-user`, nil},
|
|
}},
|
|
{"/settings/teams", []modal{
|
|
{"create-team", `.action-button__create-team`, nil},
|
|
}},
|
|
{"/settings/integrations/ticket-destinations", []modal{
|
|
{"add-integration", `.action-button__add-integration`, nil},
|
|
}},
|
|
{"/settings/integrations/mdm", nil},
|
|
{"/settings/integrations/calendars", nil},
|
|
{"/settings/integrations/change-management", nil},
|
|
{"/settings/integrations/conditional-access", nil},
|
|
{"/settings/integrations/certificate-authorities", nil},
|
|
{"/settings/integrations/identity-provider", nil},
|
|
{"/settings/integrations/sso", nil},
|
|
{"/settings/integrations/host-status-webhook", nil},
|
|
{"/account", []modal{
|
|
{"change-password", `button[class*="change-password"]`, nil},
|
|
{"get-api-token", `button[class*="api-token"]`, nil},
|
|
}},
|
|
}
|
|
|
|
func main() {
|
|
sso := flag.Bool("sso", false, "use SSO login (opens visible browser)")
|
|
cookie := flag.String("cookie", "", "session cookie value")
|
|
cookieName := flag.String("cookie-name", "Fleet-Session", "name of the session cookie")
|
|
email := flag.String("email", "", "login email")
|
|
password := flag.String("password", "", "login password")
|
|
waitSec := flag.Int("wait-time-seconds", 6, "seconds to wait for each page to load")
|
|
loginFlag := flag.Bool("login", false, "force a new login")
|
|
record := flag.String("record", "", "record a new workflow with this name")
|
|
run := flag.String("workflow", "", "run a saved workflow (use 'full' for built-in)")
|
|
list := flag.Bool("list", false, "list saved workflows")
|
|
workflowsDirFlag := flag.String("workflows-dir", "", "override workflows directory (default: tools/screencap/workflows next to the binary)")
|
|
insecure := flag.Bool("insecure", false, "ignore TLS certificate errors (for self-signed/dev certs)")
|
|
flag.Parse()
|
|
|
|
workflowsDirOverride = *workflowsDirFlag
|
|
|
|
if *list {
|
|
names, err := listWorkflows()
|
|
if err != nil {
|
|
log.Fatalf("Error listing workflows: %v", err)
|
|
}
|
|
fmt.Println("Saved workflows:")
|
|
fmt.Println(" full (built-in)")
|
|
for _, n := range names {
|
|
fmt.Printf(" %s\n", n)
|
|
}
|
|
return
|
|
}
|
|
|
|
if flag.NArg() < 1 {
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
useSSO := *sso
|
|
useCookie := *cookie != ""
|
|
usePassword := *email != "" && *password != ""
|
|
needsLogin := useSSO || useCookie || usePassword || *loginFlag
|
|
isRecording := *record != ""
|
|
|
|
waitTime := time.Duration(*waitSec) * time.Second
|
|
|
|
baseURL := strings.TrimRight(flag.Arg(0), "/")
|
|
parsed, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
log.Fatalf("Invalid base URL %q: %v", baseURL, err)
|
|
}
|
|
|
|
// Use a persistent Chrome profile so sessions survive across runs.
|
|
profileDir, err := chromeProfileDir()
|
|
if err != nil {
|
|
log.Fatalf("Error creating Chrome profile directory: %v", err)
|
|
}
|
|
// Remove stale SingletonLock left behind if a previous run crashed
|
|
// (log.Fatalf calls os.Exit which skips defers, so Chrome may not
|
|
// have cleaned up).
|
|
os.Remove(filepath.Join(profileDir, "SingletonLock"))
|
|
|
|
chromedp.WindowSize(1440, 900),
|
|
chromedp.UserDataDir(profileDir),
|
|
)
|
|
if *insecure {
|
|
opts = append(opts, chromedp.Flag("ignore-certificate-errors", true))
|
|
}
|
|
|
|
// Show browser for SSO, login, or recording.
|
|
if useSSO || *loginFlag || isRecording {
|
|
opts = append(opts, chromedp.Flag("headless", false))
|
|
}
|
|
|
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
defer allocCancel()
|
|
|
|
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf))
|
|
allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
defer allocCancel()
|
|
|
|
// Kill any orphaned Chrome processes left behind by a previous crashed run
|
|
// (log.Fatalf calls os.Exit which skips defers, so allocCancel may not
|
|
// have been called).
|
|
if out, err := exec.Command("pkill", "-f", "user-data-dir="+profileDir).CombinedOutput(); err != nil {
|
|
_ = out // no matching processes is fine
|
|
}
|
|
|
|
if needsLogin {
|
|
switch {
|
|
case useSSO || *loginFlag:
|
|
loginSSO(ctx, baseURL, waitTime)
|
|
case useCookie:
|
|
loginWithCookie(ctx, baseURL, parsed, *cookieName, *cookie, waitTime)
|
|
case usePassword:
|
|
loginPassword(ctx, baseURL, *email, *password, waitTime)
|
|
}
|
|
} else {
|
|
fmt.Println("Using saved session. (Use -sso, -email/-password, or -login to re-authenticate.)")
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Navigate(baseURL+"/dashboard"),
|
|
chromedp.Sleep(waitTime),
|
|
); err != nil {
|
|
log.Fatalf("Failed to load dashboard with saved session: %v\nTry running again with -sso or -email/-password to log in.", err)
|
|
}
|
|
}
|
|
|
|
if err := chromedp.Run(ctx, page.SetLifecycleEventsEnabled(true)); err != nil {
|
|
log.Fatalf("Failed to enable lifecycle events: %v", err)
|
|
}
|
|
|
|
if isRecording {
|
|
recordWorkflow(ctx, baseURL, parsed, *record, waitTime)
|
|
return
|
|
}
|
|
|
|
// Determine which workflow to run.
|
|
workflowName := *run
|
|
if workflowName == "" {
|
|
workflowName = "full"
|
|
}
|
|
|
|
// Build output directory.
|
|
host := strings.ReplaceAll(parsed.Host, ":", "-")
|
|
dirName := fmt.Sprintf("%s-%s-%s", time.Now().Format("2006-01-02_150405"), workflowName, host)
|
|
outDir, err := filepath.Abs(filepath.Join("screenshots", dirName))
|
|
if err != nil {
|
|
log.Fatalf("Error resolving output directory: %v", err)
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
log.Fatalf("Error creating output directory %s: %v", outDir, err)
|
|
}
|
|
fmt.Printf("Saving screenshots to %s\n", outDir)
|
|
|
|
if workflowName == "full" {
|
|
runFullWorkflow(ctx, baseURL, outDir, waitTime)
|
|
} else {
|
|
runSavedWorkflow(ctx, baseURL, outDir, workflowName, waitTime)
|
|
}
|
|
}
|
|
|
|
// recordWorkflow opens a visible browser and lets the user click around.
|
|
// It injects JS listeners to capture clicks, radio/checkbox toggles, tab
|
|
// switches, and tooltip hovers. Each time the user presses ENTER, the
|
|
// current page state and all recorded actions since the last step are saved.
|
|
func recordWorkflow(ctx context.Context, baseURL string, parsed *url.URL, name string, waitTime time.Duration) {
|
|
// Inject the recorder script into the current page.
|
|
injectRecorder(ctx)
|
|
|
|
// Re-inject on every page navigation (the script doesn't survive navigations).
|
|
chromedp.ListenTarget(ctx, func(ev interface{}) {
|
|
if _, ok := ev.(*page.EventLoadEventFired); ok {
|
|
injectRecorder(ctx)
|
|
}
|
|
})
|
|
|
|
fmt.Println()
|
|
fmt.Println("============================================================")
|
|
fmt.Printf(" Recording workflow: %s\n", name)
|
|
fmt.Println()
|
|
fmt.Println(" Browse around in the browser window.")
|
|
fmt.Println(" Clicks, radio buttons, checkboxes, tabs, and tooltip")
|
|
fmt.Println(" hovers are recorded automatically.")
|
|
fmt.Println()
|
|
fmt.Println(" Press ENTER to save a screenshot step.")
|
|
fmt.Println(" Type a name before ENTER to name the step.")
|
|
fmt.Println(" Type 'done' to finish and save the workflow.")
|
|
fmt.Println("============================================================")
|
|
fmt.Println()
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
w := &workflow{Name: name}
|
|
stepNum := 0
|
|
var prevPath string
|
|
|
|
for {
|
|
fmt.Print(" [ENTER to capture, 'done' to finish] > ")
|
|
if !scanner.Scan() {
|
|
if err := scanner.Err(); err != nil {
|
|
log.Printf(" Input error: %v", err)
|
|
}
|
|
break
|
|
}
|
|
input := strings.TrimSpace(scanner.Text())
|
|
|
|
if input == "done" {
|
|
break
|
|
}
|
|
|
|
// Get current page state from the browser.
|
|
var currentURL string
|
|
var scrollY float64
|
|
var hasModal bool
|
|
var modalTitle string
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Evaluate(`window.location.href`, ¤tURL),
|
|
chromedp.Evaluate(`window.scrollY`, &scrollY),
|
|
chromedp.Evaluate(`document.querySelector('.modal__modal_container') !== null`, &hasModal),
|
|
); err != nil {
|
|
log.Printf(" Error reading page state: %v", err)
|
|
continue
|
|
}
|
|
|
|
if hasModal {
|
|
chromedp.Run(ctx,
|
|
chromedp.Evaluate(`
|
|
(() => {
|
|
const h = document.querySelector('.modal__header');
|
|
return h ? h.textContent.trim() : '';
|
|
})()
|
|
`, &modalTitle),
|
|
)
|
|
}
|
|
|
|
// Drain recorded actions from JS.
|
|
var rawActions []recordedAction
|
|
chromedp.Run(ctx, chromedp.Evaluate(drainActionsJS, &rawActions))
|
|
|
|
// Extract the path relative to the base URL.
|
|
currentParsed, err := url.Parse(currentURL)
|
|
if err != nil {
|
|
log.Printf(" Error parsing URL: %v", err)
|
|
continue
|
|
}
|
|
path := currentParsed.Path
|
|
if currentParsed.RawQuery != "" {
|
|
path += "?" + currentParsed.RawQuery
|
|
}
|
|
|
|
// If the page changed, drop buffered actions from the prior page —
|
|
// they can't be replayed on this new page.
|
|
var actions []recordedAction
|
|
if path == prevPath || prevPath == "" {
|
|
actions = rawActions
|
|
}
|
|
prevPath = path
|
|
|
|
stepNum++
|
|
stepName := fmt.Sprintf("step-%d", stepNum)
|
|
if input != "" {
|
|
stepName = input
|
|
}
|
|
|
|
step := workflowStep{
|
|
Name: stepName,
|
|
Path: path,
|
|
ScrollY: scrollY,
|
|
HasModal: hasModal,
|
|
ModalTitle: modalTitle,
|
|
Actions: actions,
|
|
}
|
|
w.Steps = append(w.Steps, step)
|
|
|
|
desc := path
|
|
if len(actions) > 0 {
|
|
desc += fmt.Sprintf(" (%d actions", len(actions))
|
|
for _, a := range actions {
|
|
desc += fmt.Sprintf(" [%s: %s]", a.Kind, truncate(a.Text, 30))
|
|
}
|
|
desc += ")"
|
|
}
|
|
if hasModal {
|
|
desc += fmt.Sprintf(" (modal: %s)", modalTitle)
|
|
}
|
|
if scrollY > 0 {
|
|
desc += fmt.Sprintf(" (scrollY: %.0f)", scrollY)
|
|
}
|
|
fmt.Printf(" Step %d saved: %s -> %s\n", stepNum, stepName, desc)
|
|
}
|
|
|
|
if len(w.Steps) == 0 {
|
|
fmt.Println("No steps recorded. Workflow not saved.")
|
|
return
|
|
}
|
|
|
|
if err := saveWorkflow(w); err != nil {
|
|
log.Fatalf("Error saving workflow: %v", err)
|
|
}
|
|
fmt.Printf("\nWorkflow %q saved with %d steps.\n", name, len(w.Steps))
|
|
fmt.Println("Run it with:")
|
|
fmt.Printf(" make build && ./screencap -workflow %s <base-url>\n", name)
|
|
}
|
|
|
|
// injectRecorder injects the action recorder JS into the current page.
|
|
func injectRecorder(ctx context.Context) {
|
|
chromedp.Run(ctx, chromedp.Evaluate(recorderJS, nil))
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
// runSavedWorkflow replays a previously recorded workflow.
|
|
func runSavedWorkflow(ctx context.Context, baseURL, outDir, name string, waitTime time.Duration) {
|
|
w, err := loadWorkflow(name)
|
|
if err != nil {
|
|
log.Fatalf("Error loading workflow: %v", err)
|
|
}
|
|
|
|
fmt.Printf("Running workflow %q (%d steps)\n", w.Name, len(w.Steps))
|
|
|
|
for i, step := range w.Steps {
|
|
pageURL := baseURL + step.Path
|
|
fmt.Printf("[%d/%d] %s -> %s\n", i+1, len(w.Steps), step.Name, step.Path)
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Navigate(pageURL),
|
|
waitForNetworkIdle(ctx, waitTime),
|
|
); err != nil {
|
|
log.Printf(" ERROR loading %s: %v", step.Path, err)
|
|
continue
|
|
}
|
|
|
|
// Replay recorded actions before taking the screenshot.
|
|
for _, action := range step.Actions {
|
|
replayAction(ctx, action)
|
|
}
|
|
|
|
// Restore scroll position if recorded.
|
|
if step.ScrollY > 0 {
|
|
chromedp.Run(ctx,
|
|
chromedp.Evaluate(fmt.Sprintf(`window.scrollTo(0, %f)`, step.ScrollY), nil),
|
|
)
|
|
time.Sleep(250 * time.Millisecond)
|
|
}
|
|
|
|
buf, err := takeScreenshot(ctx)
|
|
if err != nil {
|
|
log.Printf(" ERROR capturing %s: %v", step.Name, err)
|
|
continue
|
|
}
|
|
|
|
filename := fmt.Sprintf("%s-1.png", sanitizeFilename(step.Name))
|
|
writeScreenshot(outDir, filename, buf)
|
|
}
|
|
|
|
fmt.Printf("\nDone! %d screenshots saved to %s\n", len(w.Steps), outDir)
|
|
}
|
|
|
|
// replayAction replays a single recorded action in the browser.
|
|
func replayAction(ctx context.Context, action recordedAction) {
|
|
if !elementExists(ctx, action.Selector) {
|
|
log.Printf(" action %s: selector %q not found, skipping", action.Kind, truncate(action.Selector, 50))
|
|
return
|
|
}
|
|
|
|
switch action.Kind {
|
|
case actionHover:
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.ScrollIntoView(action.Selector, chromedp.ByQuery),
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
// Dispatch a mouseover event to trigger tooltips.
|
|
return chromedp.Evaluate(fmt.Sprintf(`(() => {
|
|
const el = document.querySelector(%q);
|
|
if (el) {
|
|
el.dispatchEvent(new MouseEvent('mouseover', {bubbles: true}));
|
|
el.dispatchEvent(new MouseEvent('mouseenter', {bubbles: true}));
|
|
}
|
|
})()`, action.Selector), nil).Do(ctx)
|
|
}),
|
|
); err != nil {
|
|
log.Printf(" action hover: %v", err)
|
|
return
|
|
}
|
|
// Wait for tooltip to appear.
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
case actionClick, actionRadio, actionCheckbox, actionSelectTab, actionToggle:
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.ScrollIntoView(action.Selector, chromedp.ByQuery),
|
|
chromedp.Click(action.Selector, chromedp.ByQuery),
|
|
); err != nil {
|
|
log.Printf(" action %s: %v", action.Kind, err)
|
|
return
|
|
}
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
|
|
fmt.Printf(" replayed %s on %s\n", action.Kind, truncate(action.Selector, 60))
|
|
}
|
|
|
|
// runFullWorkflow runs the built-in full page + modal workflow.
|
|
func runFullWorkflow(ctx context.Context, baseURL, outDir string, waitTime time.Duration) {
|
|
for i, pg := range fullPages {
|
|
pageURL := baseURL + pg.path
|
|
baseName := pathToFilename(pg.path)
|
|
|
|
fmt.Printf("[%d/%d] %s\n", i+1, len(fullPages), pg.path)
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Navigate(pageURL),
|
|
waitForNetworkIdle(ctx, waitTime),
|
|
); err != nil {
|
|
log.Printf(" ERROR loading %s: %v", pg.path, err)
|
|
continue
|
|
}
|
|
|
|
capturePage(ctx, outDir, baseName)
|
|
|
|
for _, m := range pg.modals {
|
|
captureModal(ctx, outDir, baseName, m, pageURL, waitTime)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("\nDone! Screenshots saved to %s\n", outDir)
|
|
}
|
|
|
|
// capturePage scrolls through the page and captures one screenshot per viewport.
|
|
func capturePage(ctx context.Context, outDir, baseName string) {
|
|
var docHeight, viewportHeight float64
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Evaluate(`document.documentElement.scrollHeight`, &docHeight),
|
|
chromedp.Evaluate(`window.innerHeight`, &viewportHeight),
|
|
); err != nil {
|
|
log.Printf(" ERROR getting dimensions: %v", err)
|
|
return
|
|
}
|
|
|
|
if viewportHeight <= 0 {
|
|
log.Printf(" ERROR invalid viewport height: %.2f (docHeight=%.2f)", viewportHeight, docHeight)
|
|
return
|
|
}
|
|
|
|
part := 1
|
|
for offset := 0.0; offset < docHeight; offset += viewportHeight {
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Evaluate(fmt.Sprintf(`window.scrollTo(0, %f)`, offset), nil),
|
|
); err != nil {
|
|
log.Printf(" ERROR scrolling: %v", err)
|
|
break
|
|
}
|
|
time.Sleep(250 * time.Millisecond)
|
|
|
|
buf, err := takeScreenshot(ctx)
|
|
if err != nil {
|
|
log.Printf(" ERROR capturing part %d: %v", part, err)
|
|
break
|
|
}
|
|
|
|
filename := fmt.Sprintf("%s-%d.png", baseName, part)
|
|
if err := os.WriteFile(filepath.Join(outDir, filename), buf, 0o644); err != nil {
|
|
log.Printf(" ERROR writing %s: %v", filename, err)
|
|
break
|
|
}
|
|
part++
|
|
}
|
|
}
|
|
|
|
func captureModal(ctx context.Context, outDir, baseName string, m modal, pageURL string, waitTime time.Duration) {
|
|
if !elementExists(ctx, m.selector) {
|
|
log.Printf(" modal %q: button not found (%s), skipping", m.name, m.selector)
|
|
return
|
|
}
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.ScrollIntoView(m.selector, chromedp.ByQuery),
|
|
chromedp.Click(m.selector, chromedp.ByQuery),
|
|
); err != nil {
|
|
log.Printf(" modal %q: failed to click: %v", m.name, err)
|
|
return
|
|
}
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
if !elementExists(ctx, ".modal__modal_container") {
|
|
log.Printf(" modal %q: no modal appeared after click, skipping", m.name)
|
|
return
|
|
}
|
|
|
|
fmt.Printf(" modal: %s\n", m.name)
|
|
|
|
if buf, err := takeScreenshot(ctx); err != nil {
|
|
log.Printf(" modal %q: capture failed: %v", m.name, err)
|
|
} else {
|
|
filename := fmt.Sprintf("%s-modal-%s-1.png", baseName, m.name)
|
|
writeScreenshot(outDir, filename, buf)
|
|
}
|
|
|
|
for _, tab := range m.tabs {
|
|
if !elementExists(ctx, tab.selector) {
|
|
log.Printf(" tab %q: not found (%s), skipping", tab.name, tab.selector)
|
|
continue
|
|
}
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Click(tab.selector, chromedp.ByQuery),
|
|
); err != nil {
|
|
log.Printf(" tab %q: failed to click: %v", tab.name, err)
|
|
continue
|
|
}
|
|
|
|
time.Sleep(300 * time.Millisecond)
|
|
fmt.Printf(" tab: %s\n", tab.name)
|
|
|
|
if buf, err := takeScreenshot(ctx); err != nil {
|
|
log.Printf(" tab %q: capture failed: %v", tab.name, err)
|
|
} else {
|
|
filename := fmt.Sprintf("%s-modal-%s-%s-1.png", baseName, m.name, tab.name)
|
|
writeScreenshot(outDir, filename, buf)
|
|
}
|
|
}
|
|
|
|
closeModal(ctx)
|
|
|
|
chromedp.Run(ctx,
|
|
chromedp.Navigate(pageURL),
|
|
waitForNetworkIdle(ctx, waitTime),
|
|
)
|
|
}
|
|
|
|
func elementExists(ctx context.Context, selector string) bool {
|
|
var exists bool
|
|
chromedp.Run(ctx,
|
|
chromedp.Evaluate(fmt.Sprintf(`document.querySelector(%q) !== null`, selector), &exists),
|
|
)
|
|
return exists
|
|
}
|
|
|
|
func closeModal(ctx context.Context) {
|
|
chromedp.Run(ctx, chromedp.Evaluate(`
|
|
(() => {
|
|
const closeBtn = document.querySelector('.modal__ex');
|
|
if (closeBtn) { closeBtn.click(); return; }
|
|
document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
|
|
})()
|
|
`, nil))
|
|
time.Sleep(300 * time.Millisecond)
|
|
}
|
|
|
|
func writeScreenshot(outDir, filename string, buf []byte) {
|
|
if err := os.WriteFile(filepath.Join(outDir, filename), buf, 0o644); err != nil {
|
|
log.Printf(" ERROR writing %s: %v", filename, err)
|
|
}
|
|
}
|
|
|
|
func takeScreenshot(ctx context.Context) ([]byte, error) {
|
|
var buf []byte
|
|
err := chromedp.Run(ctx,
|
|
chromedp.ActionFunc(func(ctx context.Context) error {
|
|
var err error
|
|
buf, err = page.CaptureScreenshot().
|
|
WithFormat(page.CaptureScreenshotFormatPng).
|
|
Do(ctx)
|
|
return err
|
|
}),
|
|
)
|
|
return buf, err
|
|
}
|
|
|
|
func chromeProfileDir() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir := filepath.Join(home, ".fleet", "screencap-profile")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
return dir, nil
|
|
}
|
|
|
|
func loginSSO(ctx context.Context, baseURL string, waitTime time.Duration) {
|
|
loginURL := baseURL + "/login"
|
|
fmt.Printf("Navigating to %s ...\n", loginURL)
|
|
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Navigate(loginURL),
|
|
chromedp.WaitVisible(`.login-form__sso-btn`, chromedp.ByQuery),
|
|
chromedp.Click(`.login-form__sso-btn`, chromedp.ByQuery),
|
|
); err != nil {
|
|
log.Fatalf("Failed to initiate SSO login: %v", err)
|
|
}
|
|
|
|
fmt.Println()
|
|
fmt.Println("============================================================")
|
|
fmt.Println(" Complete the SSO login in the browser window that opened.")
|
|
fmt.Println(" Once you see the Fleet dashboard, press ENTER here.")
|
|
fmt.Println("============================================================")
|
|
fmt.Println()
|
|
|
|
bufio.NewReader(os.Stdin).ReadBytes('\n')
|
|
|
|
if err := chromedp.Run(ctx, chromedp.Sleep(waitTime)); err != nil {
|
|
log.Fatalf("Error after SSO login: %v", err)
|
|
}
|
|
fmt.Println("SSO login complete.")
|
|
}
|
|
|
|
func loginWithCookie(ctx context.Context, baseURL string, parsed *url.URL, cookieName, cookieValue string, waitTime time.Duration) {
|
|
fmt.Printf("Setting cookie %s on %s ...\n", cookieName, parsed.Host)
|
|
|
|
secure := parsed.Scheme == "https"
|
|
|
|
if err := chromedp.Run(ctx,
|
|
network.SetCookie(cookieName, cookieValue).
|
|
WithDomain(parsed.Hostname()).
|
|
WithPath("/").
|
|
WithHTTPOnly(true).
|
|
WithSecure(secure),
|
|
chromedp.Navigate(baseURL+"/dashboard"),
|
|
chromedp.Sleep(waitTime),
|
|
); err != nil {
|
|
log.Fatalf("Failed to set cookie: %v", err)
|
|
}
|
|
fmt.Println("Cookie set. Session loaded.")
|
|
}
|
|
|
|
func loginPassword(ctx context.Context, baseURL, email, password string, waitTime time.Duration) {
|
|
loginURL := baseURL + "/login"
|
|
fmt.Printf("Logging in at %s ...\n", loginURL)
|
|
if err := chromedp.Run(ctx,
|
|
chromedp.Navigate(loginURL),
|
|
chromedp.WaitVisible(`input[placeholder="Email"]`, chromedp.ByQuery),
|
|
chromedp.SendKeys(`input[placeholder="Email"]`, email, chromedp.ByQuery),
|
|
chromedp.SendKeys(`input[placeholder="Password"]`, password, chromedp.ByQuery),
|
|
chromedp.Click(`button[type="submit"]`, chromedp.ByQuery),
|
|
chromedp.WaitVisible(`body`, chromedp.ByQuery),
|
|
chromedp.Sleep(waitTime),
|
|
); err != nil {
|
|
log.Fatalf("Failed to log in: %v", err)
|
|
}
|
|
fmt.Println("Login successful.")
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Fprintf(os.Stderr, "Usage: screencap [flags] <base-url>\n\n")
|
|
fmt.Fprintf(os.Stderr, "Run the built-in full workflow:\n")
|
|
fmt.Fprintf(os.Stderr, " screencap https://fleet.example.com\n\n")
|
|
fmt.Fprintf(os.Stderr, "Record a custom workflow:\n")
|
|
fmt.Fprintf(os.Stderr, " screencap -record my-flow https://fleet.example.com\n\n")
|
|
fmt.Fprintf(os.Stderr, "Run a saved workflow:\n")
|
|
fmt.Fprintf(os.Stderr, " screencap -workflow my-flow https://other-fleet.example.com\n\n")
|
|
fmt.Fprintf(os.Stderr, "List saved workflows:\n")
|
|
fmt.Fprintf(os.Stderr, " screencap -list\n\n")
|
|
fmt.Fprintf(os.Stderr, "Auth flags (combine with any mode above):\n")
|
|
fmt.Fprintf(os.Stderr, " -sso SSO login (opens browser)\n")
|
|
fmt.Fprintf(os.Stderr, " -email/-password email/password login\n")
|
|
fmt.Fprintf(os.Stderr, " -login re-authenticate interactively\n")
|
|
fmt.Fprintf(os.Stderr, " (no auth flag) reuse saved session\n\n")
|
|
flag.PrintDefaults()
|
|
}
|
|
|
|
func waitForNetworkIdle(ctx context.Context, timeout time.Duration) chromedp.ActionFunc {
|
|
return func(ctx context.Context) error {
|
|
ch := make(chan struct{}, 1)
|
|
lctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
chromedp.ListenTarget(lctx, func(ev interface{}) {
|
|
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
})
|
|
|
|
select {
|
|
case <-ch:
|
|
return nil
|
|
case <-lctx.Done():
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func pathToFilename(path string) string {
|
|
path = strings.Trim(path, "/")
|
|
return strings.ReplaceAll(path, "/", "-")
|
|
}
|
|
|
|
// sanitizeFilename strips path separators and ".." segments from an untrusted
|
|
// name so it can be safely used as a filename inside an output directory.
|
|
// Returns "step" if the cleaned result is empty.
|
|
func sanitizeFilename(name string) string {
|
|
name = strings.ReplaceAll(name, "/", "_")
|
|
name = strings.ReplaceAll(name, `\`, "_")
|
|
name = strings.ReplaceAll(name, "..", "_")
|
|
name = filepath.Base(name)
|
|
name = strings.TrimLeft(name, ".")
|
|
if name == "" || name == "." {
|
|
return "step"
|
|
}
|
|
return name
|
|
}
|