blob: d8794516a7aeeef612e1a017d51219597aa5d513 [file] [log] [blame]
// Copyright 2021 The Chromium OS Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package screenshot
import (
"context"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"io/ioutil"
"math"
"math/rand"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"chromiumos/tast/common/action"
"chromiumos/tast/common/testexec"
"chromiumos/tast/errors"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ash"
"chromiumos/tast/local/chrome/display"
"chromiumos/tast/local/chrome/uiauto"
"chromiumos/tast/local/chrome/uiauto/nodewith"
"chromiumos/tast/local/chrome/uiauto/role"
"chromiumos/tast/local/coords"
"chromiumos/tast/lsbrelease"
"chromiumos/tast/testing"
)
const screendiffDebugVar = "screendiff.debug"
const screendiffDryrunVar = "screendiff.dryrun"
const goldServiceAccountKeyVar = "goldctl.GoldServiceAccountKey"
const goldServiceAccountKeyFile = "/tmp/gold_service_account_key.json"
// ScreenDiffVars contains a list of all variables used by the screendiff library.
var ScreenDiffVars = []string{
goldServiceAccountKeyVar,
screendiffDebugVar,
screendiffDryrunVar,
}
const goldInstance = "cros-tast"
const goldctlWorkDir = "/tmp/goldctl"
const keysFile = "keys.json"
const screenshotFile = "cropped.png"
const wholeScreenFile = "screenshot.png"
const oldScreenshotFile = "old_cropped.png"
const oldWholeScreenFile = "old_screenshot.png"
const fontConfigDir = "/etc/fonts/conf.d"
var subPixelAAFiles = map[string]string{
"10-no-sub-pixel.conf": "none",
"10-sub-pixel-bgr.conf": "BGR",
"10-sub-pixel-rgb.conf": "RGB",
"10-sub-pixel-vbgr.conf": "VGBR",
"10-sub-pixel-vrgb.conf": "VRGB"}
type testMode string
const (
local testMode = "local"
cq testMode = "cq"
postsubmit testMode = "postsubmit"
)
type screenshotState interface {
Var(name string) (string, bool)
TestName() string
Fatal(...interface{})
}
// Differ is a type for running screendiffs.
type Differ interface {
Chrome() *chrome.Chrome
Tconn() *chrome.TestConn
Diff(context.Context, string, *nodewith.Finder, ...Option) uiauto.Action
DiffWindow(context.Context, string, ...Option) uiauto.Action
GetFailedDiffs() error
DieOnFailedDiffs()
}
type differ struct {
state screenshotState
config Config
chrome *chrome.Chrome
tconn *chrome.TestConn
dir string
uiScale float64
reset func() error
goldArgs []string
failedTests []string
testMode testMode
triage string
}
// fixTwoDigitVersionNumber prefixes any two-digit milestone with a 0, to
// maintain lexicographic ordering (eg. R98 -> R098).
func fixTwoDigitVersionNumber(v string) string {
return regexp.MustCompile(`R([0-9][0-9])-`).ReplaceAllString(v, "R0$1-")
}
// NewDiffer creates a differ for a new instance of chrome with configuration specified in cfg.
func NewDiffer(ctx context.Context, state screenshotState, cfg Config) (Differ, error) {
var d = &differ{state: state, config: cfg}
if err := d.initialize(ctx); err != nil {
return nil, errors.Wrap(err, "failed to initialize screen differ")
}
return d, nil
}
// NewDifferFromChrome creates a differ for an existing chrome instance.
func NewDifferFromChrome(ctx context.Context, state screenshotState, cr *chrome.Chrome, cfg Config) (Differ, error) {
var d = &differ{state: state, chrome: cr, config: cfg}
if err := d.initialize(ctx); err != nil {
return nil, errors.Wrap(err, "failed to initialize screen differ")
}
return d, nil
}
func (d *differ) initialize(ctx context.Context) error {
if d.getBoolVar(screendiffDebugVar) {
d.config.SkipDpiNormalization = true
d.config.DryRun = true
d.config.OutputUITrees = true
d.config.DefaultOptions.SkipWindowResize = true
d.config.DefaultOptions.SkipWindowMove = true
}
if d.getBoolVar(screendiffDryrunVar) {
d.config.DryRun = true
}
if d.chrome == nil {
cr, err := chrome.New(ctx, chrome.Region(d.config.Region))
if err != nil {
return err
}
d.chrome = cr
}
release, err := lsbrelease.Load()
if err != nil {
return err
}
tconn, err := d.chrome.TestAPIConn(ctx)
if err != nil {
return err
}
d.tconn = tconn
info, displayMode, err := d.normalizeDisplayInfoAndMode(ctx)
if err != nil {
return err
}
uiScale, err := info.GetEffectiveDeviceScaleFactor()
if err != nil {
return err
}
d.uiScale = uiScale
tabletMode, err := ash.TabletModeEnabled(ctx, tconn)
if err != nil {
return err
}
region := d.config.Region
if region == "" {
region = "us"
}
nameSuffix := d.config.NameSuffix
if nameSuffix == "" {
nameSuffix = "none"
}
cpuInfo, err := d.getCPUInfo(ctx)
if err != nil {
return err
}
modelName, ok := cpuInfo["Model name"]
if !ok {
modelName = "unknown"
}
params := map[string]string{
"board": release[lsbrelease.Board],
"device_scale_factor": fmt.Sprintf("%.2f", displayMode.DeviceScaleFactor),
"display_zoom_factor": fmt.Sprintf("%.2f", info.DisplayZoomFactor),
"cpu_arch": cpuInfo["Architecture"],
"cpu_model": modelName,
"cpu_vendor": cpuInfo["Vendor ID"],
"name_suffix": nameSuffix,
"region": region,
"resolution": fmt.Sprintf("%dx%d", displayMode.WidthInNativePixels, displayMode.HeightInNativePixels),
"sub_pixel_antialiasing": currentSubPixelAntialiasingMethod(),
"scale": fmt.Sprintf("%.2f", uiScale),
"tablet_mode": fmt.Sprintf("%t", tabletMode),
"test_group": d.state.TestName(),
"version": release[lsbrelease.Version],
}
dir, ok := testing.ContextOutDir(ctx)
if !ok {
return errors.New("couldn't get output dir")
}
// Different configs may have different sets of keys.
d.dir = filepath.Join(dir, "screenshots"+d.config.Suffix())
jsonString, err := json.Marshal(params)
if err != nil {
return err
}
if err := os.MkdirAll(d.dir, 0755); err != nil {
return err
}
if err := ioutil.WriteFile(filepath.Join(d.dir, keysFile), jsonString, 0644); err != nil {
return err
}
corpus := strings.Split(d.state.TestName(), ".")[0]
baseArgs := []string{
"--corpus", corpus,
"--passfail",
}
v := url.Values{}
v.Set("corpus", corpus)
v.Set("left_filter", fmt.Sprintf("name_suffix=%s&test_group=%s", nameSuffix, d.state.TestName()))
v.Set("not_at_head", "true")
d.triage = fmt.Sprintf("https://%s-gold.skia.org/search?%s", goldInstance, v.Encode())
if strings.HasPrefix(release[lsbrelease.BuildType], "Continuous Builder") {
d.testMode = cq
builderMatch := regexp.MustCompile("-([0-9]+)$").FindStringSubmatch(release[lsbrelease.BuilderPath])
if builderMatch == nil {
return errors.Errorf("for a CQ build, the buildbucket ID should be filled in builder path (got %+v)", release[lsbrelease.BuilderPath])
}
d.goldArgs = append(baseArgs, []string{
"--cis", "buildbucket",
"--changelist", "lookup",
"--patchset_id", "lookup",
"--crs", "gerrit",
"--jobid", builderMatch[1]}...)
// Note: This will falsely pick up local builds that have been flashed with an official build.
// In the future, we may attempt to come up with a way to distinguish between these two.
} else if release[lsbrelease.BuildType] == "Official Build" {
build := release[lsbrelease.BuilderPath]
d.testMode = postsubmit
d.goldArgs = append(baseArgs, []string{
"--commit_id", fixTwoDigitVersionNumber(strings.Split(build, "/")[1]),
"--commit_metadata", fmt.Sprintf("gs://chromeos-image-archive/%s/manifest.xml", build)}...)
} else {
d.testMode = local
// TODO(crbug.com/skia/11815): once gold supports a local dev mode, replace the git hash field with that.
d.goldArgs = append(baseArgs, []string{
"--git_hash", "f1d65adb1d7cd922f4677d0f9406a4083f5fdcbc"}...)
}
hasServiceAccountKey, err := d.authenticateGold(ctx)
if err != nil {
return errors.Wrap(err, "failed to authenticate gold")
}
if !hasServiceAccountKey {
testing.ContextLogf(ctx, "Unable to access tast variable %s, so force screendiff to run as dryrun", goldServiceAccountKeyVar)
d.config.DryRun = true
}
return nil
}
func (d *differ) getDisplayInfo(ctx context.Context) (*display.Info, error) {
if d.config.DisplayID != "" {
return display.FindInfo(ctx, d.tconn, func(info *display.Info) bool {
return info.ID == d.config.DisplayID
})
}
infos, err := display.GetInfo(ctx, d.tconn)
if err != nil {
return nil, err
}
// Normalizing DPI on multiple displays with different DeviceScaleFactors won't work.
if len(infos) != 1 {
return nil, errors.New("if you want to use screenshot testing with multiple devices, you need to provide config.DisplayID")
}
return &infos[0], nil
}
func (d *differ) normalizeDisplayInfoAndMode(ctx context.Context) (*display.Info, *display.DisplayMode, error) {
info, err := d.getDisplayInfo(ctx)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to get the display info")
}
displayMode, err := info.GetSelectedMode()
if err != nil {
return nil, nil, err
}
// Pick the resolution that results in a DP/PX scale factor of 1, if available.
// This should reduce the number of screenshots that have to be approved.
if !d.config.SkipDpiNormalization {
for _, zoomFactor := range info.AvailableDisplayZoomFactors {
if math.Abs(zoomFactor*displayMode.DeviceScaleFactor-1) < 0.01 && zoomFactor != info.DisplayZoomFactor {
oldZoomFactor := info.DisplayZoomFactor
info.DisplayZoomFactor = zoomFactor
if err := display.SetDisplayProperties(ctx, d.tconn, info.ID, display.DisplayProperties{DisplayZoomFactor: &zoomFactor}); err != nil {
return nil, nil, errors.Wrap(err, "failed to normalize DPI")
}
d.reset = func() error {
return display.SetDisplayProperties(ctx, d.tconn, info.ID, display.DisplayProperties{DisplayZoomFactor: &oldZoomFactor})
}
}
}
}
return info, displayMode, nil
}
// Diff takes a screenshot of a ui element within the active window and uploads
// the result to gold. If finder is nil, takes a screenshot of the whole window.
// Collect all your diff results at the end with GetFailedDiffs() or DieOnFailedDiffs().
func (d *differ) Diff(ctx context.Context, name string, finder *nodewith.Finder, optionList ...Option) uiauto.Action {
// Prioritise per-diff options, then test options, then global defaults.
options := d.config.DefaultOptions
options.FillDefaults(Options{
Timeout: time.Second * 2,
// A window's corners are rounded, and unlike other elements, the background is inconsistent (since it's the wallpaper).
WindowBorderWidthDP: 1,
// Allow off-by-one in each channel.
// Experimental results seem to show that several boards are off by a single color in some channels,
// probably due to floating-point arithmetic. Since it's basically invisible to the end-user, ignore it.
PixelDeltaThreshold: 3,
// By default, retry once to ensure the screen hasn't changed, and fail if it has changed.
Retries: 1,
// Pick a random interval so that we don't happen to always be in sync with
// an animation (eg. If a cursor blinks every 100ms, and your interval is 1
// second, you're unlikely to pick up this issue during development.
RetryInterval: time.Duration(rand.Intn(1000))*time.Millisecond + 500*time.Millisecond})
for _, opt := range optionList {
opt(&options)
}
return func(ctx context.Context) error {
fullName := d.state.TestName() + "." + name + d.config.Suffix()
extraArgs, err := d.capture(ctx, name, finder, &options)
if err != nil {
return errors.Wrap(err, "failed to take screenshot")
}
if err := d.runGoldCommand(ctx, "imgtest", append(append([]string{"add",
"--instance", goldInstance,
"--keys-file", filepath.Join(d.dir, keysFile),
"--test-name", fullName,
"--png-file", filepath.Join(d.dir, name, screenshotFile),
}, d.goldArgs...), extraArgs...)...); err != nil {
d.failedTests = append(d.failedTests, name+d.config.Suffix())
// In case you don't have access to a filter by commit ID / release, output the logs directly.
testing.ContextLogf(ctx, "Failed test %s: %v", name, err)
}
return nil
}
}
// DiffWindow takes a screenshot of the active window and uploads the result to gold.
// Collect all your diff results at the end with GetFailedDiffs() or DieOnFailedDiffs().
func (d *differ) DiffWindow(ctx context.Context, name string, options ...Option) uiauto.Action {
return d.Diff(ctx, name, nil, options...)
}
// GetFailedDiffs returns an error containing all of the diffs that failed, if any did, or nil if all passed.
func (d *differ) GetFailedDiffs() error {
if d.reset != nil {
if err := d.reset(); err != nil {
return err
}
}
if len(d.failedTests) > 0 {
return errors.Errorf("failed screenshot tests: %s %s",
strings.Join(d.failedTests, " "), d.triage)
}
return nil
}
// DieOnFailedDiffs calls s.Fatal() if any diffs failed.
func (d *differ) DieOnFailedDiffs() {
if err := d.GetFailedDiffs(); err != nil {
d.state.Fatal("screen diffs failed: ", err)
}
}
// Chrome returns the chrome instance used in the screenshot test.
func (d *differ) Chrome() *chrome.Chrome {
return d.chrome
}
// Tconn returns the tconn associated with this chrome.
func (d *differ) Tconn() *chrome.TestConn {
return d.tconn
}
// DiffPerConfig takes a function that performs a set of screenshot diff tests, and a set of configurations to run it on,
// and runs that screenshot test on each configuration.
func DiffPerConfig(ctx context.Context, state screenshotState, configs []Config, fn func(Differ)) error {
var d = &differ{state: state}
for _, config := range configs {
d.config = config
// Upon resetting config, chrome needs to be re-initialized.
d.chrome = nil
if err := d.initialize(ctx); err != nil {
return err
}
fn(d)
}
return d.GetFailedDiffs()
}
func (d *differ) capture(ctx context.Context, screenshotName string, finder *nodewith.Finder, options *Options) ([]string, error) {
var testArgs []string
ui := uiauto.New(d.tconn).WithTimeout(options.Timeout)
window, err := ash.GetActiveWindow(ctx, d.tconn)
if err != nil {
// While it is technically possible to take screenshots of things outside of windows, it's a large source of flakiness.
// * The launcher isn't a consistent color between boards
// * Background images are inconsistent between boards
// * Different screen resolutions can't be normalized when taking pictures of a large portion of the screen
return testArgs, errors.Wrap(err, "unable to find focused window - screendiff only supports taking screenshots of apps")
}
windowBoundsDP := window.BoundsInRoot
// Even if the window already appears to be in normal state, it may actually be in the Default state. So always set to normal.
windowState, err := ash.SetWindowState(ctx, d.tconn, window.ID, ash.WMEventNormal, true /* waitForStateChange */)
if err != nil {
return testArgs, err
}
// .First() ensures it selects the outermost window element.
// Using the .Attribute name instead of Name ensures that in other locales,
// it won't attempt to translate (since it gets the name from the system,
// it's already translated).
windowFinder := nodewith.Role(role.Window).Attribute("name", window.Title).First()
shouldResize := window.CanResize && !options.SkipWindowResize
// You can only set the bounds of a window in normal state.
if windowState == ash.WindowStateNormal {
if shouldResize && (options.WindowWidthDP == 0 || options.WindowHeightDP == 0) {
return testArgs, errors.Errorf("please add screenshot.Config{DefaultOptions: screenshot.Options{WindowWidthDP: %d, WindowHeightDP: %d}} to your screendiff config", windowBoundsDP.Width, windowBoundsDP.Height)
}
// Ensure it always goes to the top-left corner of the screen. This should prevent misalignment issues.
requestedBounds := windowBoundsDP
if !options.SkipWindowMove {
requestedBounds.Left = 0
requestedBounds.Top = 0
}
if shouldResize {
requestedBounds.Width = options.WindowWidthDP
requestedBounds.Height = options.WindowHeightDP
}
// For some reason, SetWindowBounds will resize the window more precisely
// if the current window bounds are closer to the requested ones. To solve
// this, we apply this iteratively until we get the correct size.
for i := 0; i < 3 && windowBoundsDP != requestedBounds; i++ {
_, displayID, err := ash.SetWindowBounds(ctx, d.tconn, window.ID, requestedBounds, window.DisplayID)
if err != nil {
return testArgs, err
} else if displayID != window.DisplayID {
return testArgs, errors.New("Unable to move window to correct display")
}
// SetWindowBounds sometimes returns the wrong size. ui.Location is more
// trustworthy because it waits for stability.
loc, err := ui.Location(ctx, windowFinder)
if err != nil {
return testArgs, err
}
windowBoundsDP = *loc
}
if windowBoundsDP != requestedBounds {
return testArgs, errors.Errorf("Requested window bounds %+v, but got %+v", requestedBounds, windowBoundsDP)
}
}
if err := ash.WaitWindowFinishAnimating(ctx, d.tconn, window.ID); err != nil {
return testArgs, errors.Wrap(err, "Unable to wait for the window to finish animating")
}
windowBoundsDP = windowBoundsDP.WithInset(options.WindowBorderWidthDP, options.WindowBorderWidthDP)
dir := filepath.Join(d.dir, screenshotName)
if _, err := os.Stat(dir); err == nil {
return testArgs, errors.Errorf("screenshot has already been taken for %s, please give this screenshot a unique name", screenshotName)
}
if err := os.Mkdir(dir, 0755); err != nil {
return testArgs, err
}
if d.config.OutputUITrees {
uiauto.LogRootDebugInfo(ctx, d.tconn, filepath.Join(dir, "ui_tree.txt"))
}
location := &windowBoundsDP
if finder != nil {
location, err = ui.Location(ctx, finder.Ancestor(windowFinder))
if err != nil {
return testArgs, errors.Wrap(err, "failed to find node to take screenshot of")
}
}
boundsPx := coords.ConvertBoundsFromDPToPX(location.Intersection(windowBoundsDP), d.uiScale)
windowBoundsPX := coords.ConvertBoundsFromDPToPX(windowBoundsDP, d.uiScale)
testArgs = append(testArgs,
// Note: image matching algorithm and parameters should be fed as optional keys, not in keys.json,
// despite the fact that it doesn't depend on any additional data here.
"--add-test-optional-key", "image_matching_algorithm:fuzzy",
// Fuzzy matcher is a bit weird. Instead of "no more than <max different pixels> with difference of more than <delta>",
// it means "no more than <max different pixels> differing, and no individual pixel has more than <delta> difference."
// If we want to accept an image with all pixels off by one, this needs to be at least the number of pixels in the image.
"--add-test-optional-key", "fuzzy_max_different_pixels:999999999",
"--add-test-optional-key", fmt.Sprintf("fuzzy_pixel_delta_threshold:%d", options.PixelDeltaThreshold),
"--add-test-optional-key", fmt.Sprintf("cropped_resolution:%dx%d", boundsPx.Width, boundsPx.Height),
"--add-test-optional-key", fmt.Sprintf("screenshot_name:%s", screenshotName),
"--add-test-optional-key", fmt.Sprintf("window_size:%dx%d", windowBoundsPX.Width, windowBoundsPX.Height),
"--add-test-optional-key", fmt.Sprintf("window_state:%s", windowState),
)
srcOffset := image.Point{X: boundsPx.Left, Y: boundsPx.Top}
dstSize := image.Rect(0, 0, boundsPx.Width, boundsPx.Height)
// rectangles removed from the cropped image.
var removedRects []image.Rectangle
for _, subelement := range options.RemoveElements {
nodes, err := ui.NodesInfo(ctx, subelement.Ancestor(windowFinder))
if err != nil {
return testArgs, err
}
for _, node := range nodes {
removedRect := coords.ConvertBoundsFromDPToPX(node.Location, d.uiScale)
removedRect.Left -= boundsPx.Left
removedRect.Top -= boundsPx.Top
removedRects = append(removedRects, image.Rect(removedRect.Left, removedRect.Top, removedRect.Right(), removedRect.Bottom()))
}
}
takeScreenshot := func() (*image.RGBA, error) {
img, err := CaptureChromeImage(ctx, d.chrome)
if err != nil {
return nil, errors.Wrap(err, "failed to capture the chrome image")
}
// This screenshot isn't used anywhere, but is useful for context to devs.
f, err := os.Create(filepath.Join(dir, wholeScreenFile))
if err != nil {
return nil, err
}
png.Encode(f, img)
// The screenshot returned is of the whole screen. Crop it to only contain the element requested by the user.
cropped := image.NewRGBA(dstSize)
draw.Draw(cropped, dstSize, img, srcOffset, draw.Src)
for _, rect := range removedRects {
draw.Draw(cropped, rect, &image.Uniform{color.Transparent}, image.ZP, draw.Src)
}
f, err = os.Create(filepath.Join(dir, screenshotFile))
if err != nil {
return nil, err
}
png.Encode(f, cropped)
return cropped, nil
}
screenshot, err := takeScreenshot()
if err != nil {
return testArgs, err
}
var lastScreenshot *image.RGBA
if options.Retries > 1 {
if err := testing.Sleep(ctx, options.RetryInterval); err != nil {
return testArgs, err
}
}
if err := action.Retry(options.Retries, func(ctx context.Context) error {
testing.ContextLogf(ctx, "Taking screenshot again after %q", options.RetryInterval)
if err := os.Rename(filepath.Join(dir, screenshotFile), filepath.Join(dir, oldScreenshotFile)); err != nil {
return err
}
if err := os.Rename(filepath.Join(dir, wholeScreenFile), filepath.Join(dir, oldWholeScreenFile)); err != nil {
return err
}
lastScreenshot = screenshot
screenshot, err = takeScreenshot()
if err != nil {
return err
}
for y := screenshot.Bounds().Min.Y; y < screenshot.Bounds().Max.Y; y++ {
for x := screenshot.Bounds().Min.X; x < screenshot.Bounds().Max.X; x++ {
if screenshot.RGBAAt(x, y) != lastScreenshot.RGBAAt(x, y) {
return errors.Errorf("Screen has changed since the last screenshot. Images %s and %s differ at (%d, %d)", oldScreenshotFile, screenshotFile, x, y)
}
}
}
return nil
}, options.RetryInterval)(ctx); err != nil {
return testArgs, err
// Cleanup the old screenshot files, since they're the same images as the new ones.
} else if err := os.Remove(filepath.Join(dir, oldScreenshotFile)); err != nil {
return testArgs, err
} else if err := os.Remove(filepath.Join(dir, oldWholeScreenFile)); err != nil {
return testArgs, err
}
return testArgs, nil
}
// authenticateGold attempts to authenticate against the gold servers.
// It returns false if it was unable to access the credentials, or an
// error if it was unable to authenticate using the credentials.
func (d *differ) authenticateGold(ctx context.Context) (bool, error) {
key, ok := d.state.Var(goldServiceAccountKeyVar)
if !ok {
return false, nil
}
if err := ioutil.WriteFile(goldServiceAccountKeyFile, []byte(key), 0644); err != nil {
return true, err
}
return true, d.runGoldCommand(ctx, "auth", "--service-account", goldServiceAccountKeyFile)
}
func (d *differ) runGoldCommand(ctx context.Context, subcommand string, args ...string) error {
args = append([](string){subcommand, "--work-dir", goldctlWorkDir}, args...)
if d.config.DryRun {
testing.ContextLogf(ctx, `Dryrun: Would otherwise run command "goldctl %v"`, args)
return nil
}
testing.ContextLogf(ctx, `Running command "goldctl %v"`, args)
cmd := testexec.CommandContext(ctx, "goldctl", args...)
out, err := cmd.CombinedOutput()
if err != nil {
// Ignore the no newlines lint rule, because it makes it much easier to see what went wrong this way.
err = errors.Errorf("while running \"goldctl %s\"\n%s", strings.Join(args, " "), out) // NOLINT
}
return err
}
func (d *differ) getCPUInfo(ctx context.Context) (map[string]string, error) {
cmd := testexec.CommandContext(ctx, "lscpu")
out, err := cmd.Output()
if err != nil {
return nil, errors.Wrap(err, "lscpu failed")
}
result := map[string]string{}
// Each line is of the form "Key: value"
lineMatcher := regexp.MustCompile(`([^:]*):\s*(.*)`)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
match := lineMatcher.FindStringSubmatch(line)
// Gold params cannot have spaces in them. This will look like:
result[match[1]] = strings.ReplaceAll(match[2], " ", "_")
}
return result, nil
}
// getBoolVar converts a var into a boolean based on the following rules:
// No variable provided -> false
// -var=debug=true -> debug=true
// -var=debug= -> debug=true
// -var=debug=false -> debug=false
func (d *differ) getBoolVar(name string) bool {
val, ok := d.state.Var(name)
if !ok || strings.ToLower(val) == "false" {
return false
} else if val == "" || strings.ToLower(val) == "true" {
return true
}
panic(fmt.Sprintf("Variable %s must be either true, false, or empty", name))
}
func currentSubPixelAntialiasingMethod() string {
for fname, method := range subPixelAAFiles {
if _, err := os.Stat(filepath.Join(fontConfigDir, fname)); !os.IsNotExist(err) {
return method
}
}
return "unknown"
}