## 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>
244 lines
7.1 KiB
Go
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;
|
|
})()`
|