blob: 6a90230678b8b251f01be79f297b41862d640dc9 [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 scanning provides methods and constants commonly used for scanning.
package scanning
import (
"context"
"math"
"math/rand"
"os"
"path/filepath"
"regexp"
"strconv"
"time"
"chromiumos/tast/common/testexec"
"chromiumos/tast/ctxutil"
"chromiumos/tast/errors"
"chromiumos/tast/fsutil"
"chromiumos/tast/local/chrome"
"chromiumos/tast/local/chrome/ash"
"chromiumos/tast/local/chrome/uiauto"
"chromiumos/tast/local/chrome/uiauto/faillog"
"chromiumos/tast/local/chrome/uiauto/filesapp"
"chromiumos/tast/local/chrome/uiauto/scanapp"
"chromiumos/tast/local/printing/cups"
"chromiumos/tast/local/printing/document"
"chromiumos/tast/local/printing/ippusbbridge"
"chromiumos/tast/local/printing/usbprinter"
"chromiumos/tast/testing"
)
const (
// SourceImage is the image used to configure the virtual USB scanner.
SourceImage = "scan_source.jpg"
// Attributes is the path to the attributes used to configure the virtual
// USB scanner.
Attributes = "/usr/local/etc/virtual-usb-printer/ipp_attributes.json"
// Descriptors is the path to the descriptors used to configure the virtual
// USB scanner.
Descriptors = "/usr/local/etc/virtual-usb-printer/ippusb_printer.json"
// EsclCapabilities is the path to the capabilities used to configure the
// virtual USB scanner.
EsclCapabilities = "/usr/local/etc/virtual-usb-printer/escl_capabilities.json"
// DefaultScanPattern is the pattern used to find files in the default
// scan-to location.
DefaultScanPattern = filesapp.MyFilesPath + "/scan*_*.*"
// MMPerInch is the conversion factor from inches to mm.
MMPerInch = 25.4
)
// identifyOutputRegex parses out the width, height and colorspace from the
// output of `identify someImage`.
var identifyOutputRegex = regexp.MustCompile(`^.+ PNG (?P<width>[0-9]+)x(?P<height>[0-9]+).+ 8-bit (?P<colorspace>sRGB|Gray 256c|Gray 2c)`)
// GetScan returns the filepath of the scanned file found using pattern.
func GetScan(pattern string) (string, error) {
scans, err := filepath.Glob(pattern)
if err != nil {
return "", err
}
if len(scans) != 1 {
return "", errors.Errorf("found too many scans: got %v; want 1", len(scans))
}
return scans[0], nil
}
// RemoveScans removes all of the scanned files found using pattern.
func RemoveScans(pattern string) error {
scans, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, scan := range scans {
if err = os.Remove(scan); err != nil {
return errors.Wrapf(err, "failed to remove %s", scan)
}
}
return nil
}
// calculateNumScans returns the minimum number of scans necessary to test each
// option at least once.
func calculateNumScans(numColorModes, numPageSizes, numResolutions int) int {
numScans := numColorModes
if numPageSizes > numScans {
numScans = numPageSizes
}
if numResolutions > numScans {
numScans = numResolutions
}
return numScans
}
// toIdentifyColorspace converts from `colorMode` to the colorspace output by
// `identify someImage`.
func toIdentifyColorspace(colorMode scanapp.ColorMode) (string, error) {
switch colorMode {
case scanapp.ColorModeBlackAndWhite:
return "Gray 2c", nil
case scanapp.ColorModeGrayscale:
return "Gray 256c", nil
case scanapp.ColorModeColor:
return "sRGB", nil
default:
return "", errors.Errorf("Unable to convert color mode: %v to identify colorspace", colorMode)
}
}
// calculateExpectedDimensions returns the expected height and width in pixels
// for an image of size `pageSize` and resolution `resolution`.
func calculateExpectedDimensions(pageSize scanapp.PageSize, resolution scanapp.Resolution, sourceDimensions SourceDimensions) (expectedHeight, expectedWidth float64, err error) {
var heightMM float64
var widthMM float64
switch pageSize {
case scanapp.PageSizeA3:
widthMM = 297
heightMM = 420
case scanapp.PageSizeA4:
widthMM = 210
heightMM = 297
case scanapp.PageSizeB4:
widthMM = 257
heightMM = 364
case scanapp.PageSizeLegal:
widthMM = 215.9
heightMM = 355.6
case scanapp.PageSizeLetter:
widthMM = 215.9
heightMM = 279.4
case scanapp.PageSizeTabloid:
widthMM = 279.4
heightMM = 431.8
case scanapp.PageSizeFitToScanArea:
widthMM = sourceDimensions.WidthMM
heightMM = sourceDimensions.HeightMM
default:
return -1, -1, errors.Errorf("Unrecognized page size: %v", pageSize)
}
resInt, err := resolution.ToInt()
if err != nil {
return -1, -1, err
}
return heightMM / MMPerInch * float64(resInt), widthMM / MMPerInch * float64(resInt), nil
}
// calculateAcceptablePixelDifference returns the allowable threshold for a
// scanned image's actual size versus its theoretical size. This threshold is
// 0.25mm with a minimum of 1 to account for rounding.
func calculateAcceptablePixelDifference(resolution scanapp.Resolution) (float64, error) {
resInt, err := resolution.ToInt()
if err != nil {
return -1, err
}
return math.Max(1.0, 0.25/MMPerInch*float64(resInt)), nil
}
// verifyScannedImage verifies that the scanned image with location `scan` has
// the correct width, height and resolution as reported by `identify`.
func verifyScannedImage(ctx context.Context, scan string, pageSize scanapp.PageSize, resolution scanapp.Resolution, colorMode scanapp.ColorMode, sourceDimensions SourceDimensions) error {
cmd := testexec.CommandContext(ctx, "identify", scan)
identifyBytes, err := cmd.Output()
if err != nil {
return err
}
expectedHeight, expectedWidth, err := calculateExpectedDimensions(pageSize, resolution, sourceDimensions)
if err != nil {
return err
}
match := identifyOutputRegex.FindStringSubmatch(string(identifyBytes))
if match == nil || len(match) < 4 {
return errors.Errorf("Unable to parse identify output: %s", string(identifyBytes))
}
threshold, err := calculateAcceptablePixelDifference(resolution)
if err != nil {
return errors.Wrap(err, "Unable to calculate threshold")
}
for i, name := range identifyOutputRegex.SubexpNames() {
if name == "width" {
width, err := strconv.Atoi(match[i])
if err != nil {
return err
}
if math.Abs(expectedWidth-float64(width)) > threshold {
return errors.Errorf("Width: got %d, expected %f", width, expectedWidth)
}
}
if name == "height" {
height, err := strconv.Atoi(match[i])
if err != nil {
return err
}
if math.Abs(expectedHeight-float64(height)) > threshold {
return errors.Errorf("Height: got %d, expected %f", height, expectedHeight)
}
}
if name == "colorspace" {
colorSpace, err := toIdentifyColorspace(colorMode)
if err != nil {
return err
}
if colorSpace != match[i] {
return errors.Errorf("Colorspace: got %s, expected %s", match[i], colorSpace)
}
}
}
return nil
}
// TestingStruct contains the parameters used when testing the scanapp settings
// in RunAppSettingsTests.
type TestingStruct struct {
Name string
Settings scanapp.ScanSettings
GoldenFile string
}
// ScannerStruct contains the necessary parameters for setting up the virtual usb printer.
type ScannerStruct struct {
Descriptors string
Attributes string
EsclCaps string
}
// SourceDimensions contain the height and width of a scan source, in mm.
type SourceDimensions struct {
HeightMM float64 `json:"Height"`
WidthMM float64 `json:"Width"`
}
// SupportedSource describes the options supported by a particular scan source.
type SupportedSource struct {
SourceType scanapp.Source `json:"SourceType"`
SupportedColorModes []scanapp.ColorMode `json:"ColorModes"`
SupportedPageSizes []scanapp.PageSize `json:"PageSizes"`
SupportedResolutions []scanapp.Resolution `json:"Resolutions"`
// SourceDimensions only needs to be present for flatbed sources. They can
// be determined by running `lorgnette_cli get_json_caps --scanner=$SCANNER`
// for any particular scanner. Note that the flatbed and ADF sources often
// have different dimensions - make sure to choose the dimension of the
// flatbed source.
SourceDimensions SourceDimensions `json:"SourceDimensions"`
}
// ScannerDescriptor contains the parameters used to test the scan app on real
// hardware.
type ScannerDescriptor struct {
ScannerName string `json:"Name"`
SupportedSources []SupportedSource `json:"Sources"`
}
// RunAppSettingsTests takes in the Chrome instance and the specific testing parameters
// and performs the test, including attaching the virtual USB printer, launching
// the scanapp, clicking through the settings, and verifying proper image output.
func RunAppSettingsTests(ctx context.Context, s *testing.State, cr *chrome.Chrome, testParams []TestingStruct, scannerParams ScannerStruct) {
// Use cleanupCtx for any deferred cleanups in case of timeouts or
// cancellations on the shortened context.
cleanupCtx := ctx
ctx, cancel := ctxutil.Shorten(ctx, 5*time.Second)
defer cancel()
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
s.Fatal("Failed to connect Test API: ", err)
}
defer faillog.DumpUITreeOnError(cleanupCtx, s.OutDir(), s.HasError, tconn)
printer, err := usbprinter.Start(ctx,
usbprinter.WithDescriptors(scannerParams.Descriptors),
usbprinter.WithAttributes(scannerParams.Attributes),
usbprinter.WithESCLCapabilities(scannerParams.EsclCaps),
usbprinter.ExpectUdevEventOnStop(),
usbprinter.WaitUntilConfigured())
if err != nil {
s.Fatal("Failed to attach virtual printer: ", err)
}
s.Logf("Started virtual printer: %s", printer.VisibleName)
defer func(ctx context.Context) {
if err := printer.Stop(ctx); err != nil {
s.Error("Failed to stop printer: ", err)
}
}(cleanupCtx)
if err = ippusbbridge.WaitForSocket(ctx, printer.DevInfo); err != nil {
s.Fatal("Failed to wait for ippusb_bridge socket: ", err)
}
if err = cups.RestartPrintingSystem(ctx); err != nil {
s.Fatal("Failed to restart printing system: ", err)
}
if _, err := ash.WaitForNotification(ctx, tconn, 30*time.Second, ash.WaitMessageContains(printer.VisibleName)); err != nil {
s.Fatal("Failed to wait for printer notification: ", err)
}
if err = ippusbbridge.ContactPrinterEndpoint(ctx, printer.DevInfo, "/eSCL/ScannerCapabilities"); err != nil {
s.Fatal("Failed to get scanner status over ippusb_bridge socket: ", err)
}
// Launch the Scan app, configure the settings, and perform scans.
app, err := scanapp.Launch(ctx, tconn)
if err != nil {
s.Fatal("Failed to launch app: ", err)
}
if err := app.ClickMoreSettings()(ctx); err != nil {
s.Fatal("Failed to expand More settings: ", err)
}
for _, test := range testParams {
settings := test.Settings
settings.Scanner = printer.VisibleName
s.Run(ctx, test.Name, func(ctx context.Context, s *testing.State) {
defer faillog.DumpUITreeWithScreenshotOnError(cleanupCtx, s.OutDir(), s.HasError, cr, "ui_tree_"+test.Name)
defer func() {
if err := RemoveScans(DefaultScanPattern); err != nil {
s.Error("Failed to remove scans: ", err)
}
}()
// Make sure printer connected notifications don't cover the Scan button.
if err := ash.CloseNotifications(ctx, tconn); err != nil {
s.Fatal("Failed to close notifications: ", err)
}
if err := uiauto.Combine("scan",
app.SetScanSettings(settings),
app.Scan(),
app.ClickDone(),
)(ctx); err != nil {
s.Fatal("Failed to perform scan: ", err)
}
scan, err := GetScan(DefaultScanPattern)
if err != nil {
s.Fatal("Failed to find scan: ", err)
}
diffPath := filepath.Join(s.OutDir(), test.Name+"_diff.txt")
if err := document.CompareFiles(ctx, scan, s.DataPath(test.GoldenFile), diffPath); err != nil {
s.Error("Scan differs from golden file: ", err)
saveScanPath := filepath.Join(s.OutDir(), test.Name+filepath.Ext(scan))
if err := fsutil.MoveFile(scan, saveScanPath); err != nil {
s.Error("Unable to preserve scanned file output: ", err)
}
}
})
}
// Intentionally stop the printer early to trigger shutdown in
// ippusb_bridge. Without this, cleanup may have to wait for other processes
// to finish using the printer (e.g. CUPS background probing).
//
// TODO(b/210134772): Investigate if this remains necessary.
if err := printer.Stop(cleanupCtx); err != nil {
s.Error("Failed to stop printer: ", err)
}
}
// RunHardwareTests tests that the scan app can select each of the options
// provided by `scanner`. This function is intended to be run on real hardware,
// not the virtual USB printer.
func RunHardwareTests(ctx context.Context, s *testing.State, cr *chrome.Chrome, scanner ScannerDescriptor) {
ctx, cancel := ctxutil.Shorten(ctx, 5*time.Second)
defer cancel()
defer faillog.DumpUITreeWithScreenshotOnError(ctx, s.OutDir(), s.HasError, cr, "ui_tree")
tconn, err := cr.TestAPIConn(ctx)
if err != nil {
s.Fatal("Failed to connect Test API: ", err)
}
app, err := scanapp.LaunchWithPollOpts(ctx, testing.PollOptions{Interval: 300 * time.Millisecond, Timeout: 1 * time.Minute}, tconn)
if err != nil {
s.Fatal("Failed to launch app: ", err)
}
if err := app.ClickMoreSettings()(ctx); err != nil {
s.Fatal("Failed to expand More settings: ", err)
}
// Loop through all of the supported options. Skip file type for now, since
// that is not a property of the scanners themselves and we're not
// performing any real scans.
if err := app.SelectScanner(scanner.ScannerName)(ctx); err != nil {
s.Fatalf("Failed to select scanner: %s: %v", scanner.ScannerName, err)
}
// Sleep to allow the supported sources to load and stabilize.
// TODO(b/211712633): Once there is a way to verify the selection of a
// listbox, add that logic to app.SelectSource() and remove this sleep.
if err := testing.Sleep(ctx, 2*time.Second); err != nil {
s.Fatal("Failed to sleep after selecting scanner: ", err)
}
rand.Seed(time.Now().UnixNano())
for _, source := range scanner.SupportedSources {
s.Log("Testing source: ", source.SourceType)
if err := app.SelectSource(source.SourceType)(ctx); err != nil {
s.Fatalf("Failed to select source: %s: %v", source.SourceType, err)
}
// Sleep to allow the source-specific options to load and stabilize.
// TODO(b/211712633): Once there is a way to verify the selecion of a
// listbox, add that logic to app.SelectColorMode(),
// app.SelectPageSize(), app.SelectResolution() and remove this sleep.
if err := testing.Sleep(ctx, 2*time.Second); err != nil {
s.Fatal("Failed to sleep after selecting source: ", err)
}
// For flatbed sources, perform scans with randomized settings
// combinations until each setting has been tested at least once.
if source.SourceType == scanapp.SourceFlatbed {
defer func() {
if err := RemoveScans(DefaultScanPattern); err != nil {
s.Error("Failed to remove scans: ", err)
}
}()
if err := app.SelectFileType(scanapp.FileTypePNG)(ctx); err != nil {
s.Fatal("Failed to select file type: ", scanapp.FileTypePNG)
}
}
rand.Shuffle(len(source.SupportedColorModes), func(i, j int) {
source.SupportedColorModes[i], source.SupportedColorModes[j] = source.SupportedColorModes[j], source.SupportedColorModes[i]
})
rand.Shuffle(len(source.SupportedPageSizes), func(i, j int) {
source.SupportedPageSizes[i], source.SupportedPageSizes[j] = source.SupportedPageSizes[j], source.SupportedPageSizes[i]
})
rand.Shuffle(len(source.SupportedResolutions), func(i, j int) {
source.SupportedResolutions[i], source.SupportedResolutions[j] = source.SupportedResolutions[j], source.SupportedResolutions[i]
})
numScans := calculateNumScans(len(source.SupportedColorModes), len(source.SupportedPageSizes), len(source.SupportedResolutions))
for i := 0; i < numScans; i++ {
colorMode := source.SupportedColorModes[i%len(source.SupportedColorModes)]
if err := app.SelectColorMode(colorMode)(ctx); err != nil {
s.Fatalf("Failed to select color mode: %s: %v", colorMode, err)
}
pageSize := source.SupportedPageSizes[i%len(source.SupportedPageSizes)]
if err := app.SelectPageSize(pageSize)(ctx); err != nil {
s.Fatalf("Failed to select page size: %s: %v", pageSize, err)
}
resolution := source.SupportedResolutions[i%len(source.SupportedResolutions)]
if err := app.SelectResolution(resolution)(ctx); err != nil {
s.Fatalf("Failed to select resolution: %s, %v", resolution, err)
}
if source.SourceType != scanapp.SourceFlatbed {
continue
}
// Make sure printer connected notifications don't cover the
// Scan button.
if err := ash.CloseNotifications(ctx, tconn); err != nil {
s.Fatal("Failed to close notifications: ", err)
}
s.Logf("Testing scan combination: {%v %v %v}", colorMode, pageSize, resolution)
if err := uiauto.Combine("scan",
app.Scan(),
app.ClickDone(),
)(ctx); err != nil {
s.Fatal("Failed to perform scan: ", err)
}
scan, err := GetScan(DefaultScanPattern)
if err != nil {
s.Fatal("Failed to find scan: ", err)
}
err = verifyScannedImage(ctx, scan, pageSize, resolution, colorMode, source.SourceDimensions)
if err != nil {
s.Error("Failed to verify scanned image: ", err)
}
err = RemoveScans(DefaultScanPattern)
if err != nil {
s.Error("Failed to remove scans: ", err)
}
}
}
}