Files
George KarrandLuke Heath 51da3581e6 Adding new screencap tool to help with automated testing (#42437)
## 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 -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](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>
2026-05-21 13:49:56 -05:00

244 lines
7.1 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
)
// workflowsDirOverride, when non-empty, takes precedence over the default
// source-file-relative workflows directory. Set from the -workflows-dir flag.
var workflowsDirOverride string
// actionKind describes the type of user interaction recorded.
type actionKind string
const (
actionClick actionKind = "click"
actionHover actionKind = "hover"
actionRadio actionKind = "radio"
actionCheckbox actionKind = "checkbox"
actionSelectTab actionKind = "tab"
actionToggle actionKind = "toggle"
)
// recordedAction is a single user interaction captured during recording.
type recordedAction struct {
Kind actionKind `json:"kind"`
Selector string `json:"selector"`
Text string `json:"text,omitempty"`
}
// workflowStep is a single step in a recorded workflow.
type workflowStep struct {
Name string `json:"name"`
Path string `json:"path"`
ScrollY float64 `json:"scroll_y,omitempty"`
HasModal bool `json:"has_modal,omitempty"`
ModalTitle string `json:"modal_title,omitempty"`
Actions []recordedAction `json:"actions,omitempty"`
}
// workflow is a named, replayable sequence of screenshot steps.
type workflow struct {
Name string `json:"name"`
Steps []workflowStep `json:"steps"`
}
// workflowsDir returns the directory where workflow JSON files are stored.
// By default it resolves to tools/screencap/workflows/ relative to this source
// file, so workflows live in the repo and can be committed and shared. The
// -workflows-dir flag (workflowsDirOverride) takes precedence when set.
func workflowsDir() (string, error) {
var dir string
if workflowsDirOverride != "" {
abs, err := filepath.Abs(workflowsDirOverride)
if err != nil {
return "", err
}
dir = abs
} else {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return "", fmt.Errorf("cannot determine source file path")
}
dir = filepath.Join(filepath.Dir(thisFile), "workflows")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func workflowPath(name string) (string, error) {
if name == "" ||
name != filepath.Base(name) ||
strings.Contains(name, "..") ||
strings.ContainsAny(name, `/\`) {
return "", fmt.Errorf("invalid workflow name %q", name)
}
dir, err := workflowsDir()
if err != nil {
return "", err
}
return filepath.Join(dir, name+".json"), nil
}
func saveWorkflow(w *workflow) error {
path, err := workflowPath(w.Name)
if err != nil {
return err
}
data, err := json.MarshalIndent(w, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(path, data, 0o644)
}
func loadWorkflow(name string) (*workflow, error) {
path, err := workflowPath(name)
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("workflow %q not found: %w", name, err)
}
var w workflow
if err := json.Unmarshal(data, &w); err != nil {
return nil, fmt.Errorf("invalid workflow file: %w", err)
}
return &w, nil
}
func listWorkflows() ([]string, error) {
dir, err := workflowsDir()
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var names []string
for _, e := range entries {
if filepath.Ext(e.Name()) == ".json" {
names = append(names, e.Name()[:len(e.Name())-5])
}
}
return names, nil
}
// recorderJS is injected into the page during recording mode.
// It listens for clicks on interactive elements and records them
// with a stable CSS selector so they can be replayed.
const recorderJS = `(() => {
if (window.__screencapRecorder) return;
window.__screencapRecorder = true;
window.__screencapActions = [];
function getSelector(el) {
// Try data-text attribute (used by Fleet tabs).
if (el.dataset && el.dataset.text) {
return '[data-text="' + el.dataset.text + '"]';
}
// Try id.
if (el.id) {
return '#' + CSS.escape(el.id);
}
// Try unique class combination.
if (el.className && typeof el.className === 'string') {
const classes = el.className.trim().split(/\s+/).filter(c => c && !c.startsWith('active') && !c.startsWith('hover'));
if (classes.length > 0) {
const sel = '.' + classes.map(c => CSS.escape(c)).join('.');
if (document.querySelectorAll(sel).length === 1) {
return sel;
}
}
}
// Build a path from parent.
const parts = [];
let node = el;
while (node && node !== document.body) {
let part = node.tagName.toLowerCase();
if (node.id) {
parts.unshift('#' + CSS.escape(node.id) + ' > ' + part);
break;
}
const parent = node.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter(c => c.tagName === node.tagName);
if (siblings.length > 1) {
part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
}
}
parts.unshift(part);
node = parent;
}
return parts.join(' > ');
}
function classify(el) {
const tag = el.tagName.toLowerCase();
const type = (el.getAttribute('type') || '').toLowerCase();
if (tag === 'input' && type === 'radio') return 'radio';
if (tag === 'input' && type === 'checkbox') return 'checkbox';
if (el.getAttribute('role') === 'tab' || el.dataset.text) return 'tab';
if (el.classList.contains('toggle') || el.getAttribute('role') === 'switch') return 'toggle';
return 'click';
}
document.addEventListener('click', (e) => {
// Walk up to find the meaningful interactive element.
let target = e.target;
for (let i = 0; i < 5 && target; i++) {
const tag = target.tagName.toLowerCase();
if (tag === 'button' || tag === 'a' || tag === 'input' || tag === 'select' ||
target.getAttribute('role') === 'tab' || target.getAttribute('role') === 'switch' ||
target.dataset.text || target.classList.contains('toggle')) {
break;
}
target = target.parentElement;
}
if (!target) target = e.target;
const action = {
kind: classify(target),
selector: getSelector(target),
text: (target.textContent || '').trim().substring(0, 100),
};
window.__screencapActions.push(action);
}, true);
// Track hovers on elements with tooltips.
document.addEventListener('mouseover', (e) => {
const target = e.target;
if (target.title || target.getAttribute('data-tooltip') || target.getAttribute('aria-describedby') ||
target.closest('[data-tip]') || target.closest('[data-tooltip-id]')) {
const el = target.closest('[data-tip]') || target.closest('[data-tooltip-id]') || target;
const action = {
kind: 'hover',
selector: getSelector(el),
text: (el.title || el.getAttribute('data-tip') || el.getAttribute('data-tooltip') || '').substring(0, 100),
};
// Deduplicate consecutive hovers on the same element.
const last = window.__screencapActions[window.__screencapActions.length - 1];
if (!last || last.kind !== 'hover' || last.selector !== action.selector) {
window.__screencapActions.push(action);
}
}
}, true);
})()`
// drainActionsJS returns the recorded actions and clears the buffer.
const drainActionsJS = `(() => {
const actions = window.__screencapActions || [];
window.__screencapActions = [];
return actions;
})()`