diff --git a/cmd/mfp-test/Makefile b/cmd/mfp-test/Makefile new file mode 100644 index 00000000..99bc5e89 --- /dev/null +++ b/cmd/mfp-test/Makefile @@ -0,0 +1,4 @@ +SUBDIRS = test +CLEAN = mfp-test + +include ../../Rules.mak diff --git a/cmd/mfp-test/main.go b/cmd/mfp-test/main.go new file mode 100644 index 00000000..2aecab84 --- /dev/null +++ b/cmd/mfp-test/main.go @@ -0,0 +1,15 @@ +// MFP - Multi-Function Printers and scanners toolkit +// cmd/mfp-test - Print system testing pipeline +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// The main() function. + +package main + +import "github.com/OpenPrinting/go-mfp/cmd/mfp-test/test" + +func main() { + test.Command.Main(nil) +} diff --git a/cmd/mfp-test/test/Makefile b/cmd/mfp-test/test/Makefile new file mode 100644 index 00000000..e7eb5b5e --- /dev/null +++ b/cmd/mfp-test/test/Makefile @@ -0,0 +1 @@ +include ../../../Rules.mak diff --git a/cmd/mfp-test/test/capture.go b/cmd/mfp-test/test/capture.go new file mode 100644 index 00000000..9d9e883b --- /dev/null +++ b/cmd/mfp-test/test/capture.go @@ -0,0 +1,87 @@ +// MFP - Multi-Function Printers and scanners toolkit +// The "mfp-test" command +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// Document capture + +package test + +import ( + "io" + "sync" + + "github.com/OpenPrinting/go-mfp/abstract" +) + +// CapturedDoc holds a single captured print document +// with its negotiated job parameters and raw bytes. +type CapturedDoc struct { + Params abstract.PrinterRequest + Data []byte +} + +// DocumentCapture implements abstract.Printer and collects +// all incoming print documents for later inspection. +// It is safe for concurrent use. +type DocumentCapture struct { + mu sync.Mutex + docs []CapturedDoc + done chan struct{} +} + +// NewDocumentCapture creates a new DocumentCapture. +func NewDocumentCapture() *DocumentCapture { + return &DocumentCapture{ + done: make(chan struct{}), + } +} + +// PrintDocument implements abstract.Printer. +// It reads the full document body and stores it along with params. +func (dc *DocumentCapture) PrintDocument( + params abstract.PrinterRequest, body io.Reader) error { + + data, err := io.ReadAll(body) + if err != nil { + return err + } + + dc.mu.Lock() + dc.docs = append(dc.docs, CapturedDoc{ + Params: params, + Data: data, + }) + dc.mu.Unlock() + + // Signal that at least one document has arrived. + select { + case <-dc.done: + default: + close(dc.done) + } + + return nil +} + +// OnDocument returns a channel that is closed when the first +// document is received. Useful for waiting without polling. +func (dc *DocumentCapture) OnDocument() <-chan struct{} { + return dc.done +} + +// Wait blocks until at least one document has been captured. +func (dc *DocumentCapture) Wait() { + <-dc.done +} + +// Docs returns a snapshot of all captured documents so far. +func (dc *DocumentCapture) Docs() []CapturedDoc { + dc.mu.Lock() + defer dc.mu.Unlock() + + out := make([]CapturedDoc, len(dc.docs)) + copy(out, dc.docs) + return out +} diff --git a/cmd/mfp-test/test/command.go b/cmd/mfp-test/test/command.go new file mode 100644 index 00000000..eea7bb0b --- /dev/null +++ b/cmd/mfp-test/test/command.go @@ -0,0 +1,248 @@ +// MFP - Multi-Function Printers and scanners toolkit +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// mfp-test command definition + +package test + +import ( + "context" + "fmt" + "image" + "image/color" + "image/png" + "net" + "os" + "os/exec" + "strconv" + "time" + + "github.com/OpenPrinting/go-mfp/argv" + "github.com/OpenPrinting/go-mfp/log" + "github.com/OpenPrinting/go-mfp/modeling" + "github.com/OpenPrinting/go-mfp/transport" +) + +// DefaultTCPPort is the default IPP server TCP port. +const DefaultTCPPort = 60000 + +// DefaultQueueName is the default CUPS queue name. +const DefaultQueueName = "mfp-test" + +// Command is the mfp-test command description. +var Command = argv.Command{ + Name: "mfp-test", + Help: "Print system testing pipeline", + Options: []argv.Option{ + { + Name: "-m", + Aliases: []string{"--model"}, + Help: "printer model file", + HelpArg: "file", + Singleton: true, + Validate: argv.ValidateAny, + Complete: argv.CompleteOSPath, + }, + { + Name: "-P", + Aliases: []string{"--port"}, + Help: fmt.Sprintf("IPP server TCP port (default %d)", DefaultTCPPort), + HelpArg: "port", + Singleton: true, + Validate: argv.ValidateUint16, + }, + { + Name: "-n", + Aliases: []string{"--name"}, + Help: fmt.Sprintf("CUPS queue name (default %q)", DefaultQueueName), + HelpArg: "name", + Singleton: true, + Validate: argv.ValidateAny, + }, + { + Name: "-o", + Aliases: []string{"--output"}, + Help: "write JSON report to file", + HelpArg: "file", + Singleton: true, + Validate: argv.ValidateAny, + Complete: argv.CompleteOSPath, + }, + { + Name: "--threshold", + Help: "minimum similarity score to pass (0.0-1.0, default 0.95)", + HelpArg: "score", + Singleton: true, + Validate: argv.ValidateAny, + }, + { + Name: "--list", + Help: "list all test configurations and exit", + }, + { + Name: "--batch", + Help: "run all test configurations", + Singleton: true, + }, + { + Name: "--single", + Help: "run a single test configuration by name", + HelpArg: "name", + Singleton: true, + Validate: argv.ValidateAny, + }, + { + Name: "-v", + Aliases: []string{"--verbose"}, + Help: "enable verbose output", + }, + argv.HelpOption, + }, + Handler: cmdTestHandler, +} + +// cmdTestHandler is the top-level handler for the mfp-test command. +func cmdTestHandler(ctx context.Context, inv *argv.Invocation) error { + level := log.LevelInfo + if inv.Flag("-v") { + level = log.LevelTrace + } + logger := log.NewLogger(level, log.Console) + ctx = log.NewContext(ctx, logger) + + // Model file is required: without it, NewIPPServer() returns nil. + modelfile, ok := inv.Get("-m") + if !ok { + return fmt.Errorf("model file required: use -m ") + } + + model, err := modeling.NewModel() + if err != nil { + return err + } + defer model.Close() + + if err := model.Load(modelfile); err != nil { + return fmt.Errorf("load model %q: %w", modelfile, err) + } + + // Parse port number + port := DefaultTCPPort + if portStr, ok := inv.Get("-P"); ok { + p, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port %q: %w", portStr, err) + } + port = p + } + + // Create document capture backend + capture := NewDocumentCapture() + + // Create virtual IPP printer from model and hook capture into it + ippPrinter := model.NewIPPServer() + if ippPrinter == nil { + return fmt.Errorf("model has no IPP printer attributes") + } + ippPrinter.SetPrintBackend(capture) + + // Register IPP handler on the URL path /ipp/print + mux := transport.NewPathMux() + mux.Add("/ipp/print", ippPrinter) + + // Open TCP port and start HTTP server (IPP runs over HTTP) + addr := fmt.Sprintf("localhost:%d", port) + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + + srvr := transport.NewServer(ctx, nil, mux) + log.Info(ctx, "virtual IPP printer at ipp://%s/ipp/print", addr) + go srvr.Serve(ln) + defer srvr.Close() + + // Get CUPS queue name + queueName := DefaultQueueName + if name, ok := inv.Get("-n"); ok { + queueName = name + } + + // Register virtual printer with CUPS + ippURL := fmt.Sprintf("ipp://localhost:%d/ipp/print", port) + if err := CreateCUPSQueue(ctx, queueName, ippURL); err != nil { + return err + } + // WithoutCancel preserves logging and values from ctx but + // prevents cancellation from stopping the cleanup operation. + defer RemoveCUPSQueue(context.WithoutCancel(ctx), queueName) + + log.Info(ctx, "CUPS queue %q ready at %s", queueName, ippURL) + + // Generate a test PNG image and send it through the full pipeline + imgPath, err := generateTestPNG() + if err != nil { + return fmt.Errorf("generate test image: %w", err) + } + defer os.Remove(imgPath) + + log.Info(ctx, "sending test PNG via lp...") + lpCmd := exec.CommandContext(ctx, "lp", "-d", queueName, imgPath) + if out, err := lpCmd.CombinedOutput(); err != nil { + return fmt.Errorf("lp -d %s: %w: %s", queueName, err, out) + } + + // Wait for the document to arrive at the capture backend + select { + case <-capture.OnDocument(): + case <-time.After(30 * time.Second): + return fmt.Errorf("timeout: no document received after 30s") + case <-ctx.Done(): + return nil + } + + // Report what was captured + docs := capture.Docs() + for i, d := range docs { + log.Info(ctx, "captured doc %d: %d bytes, format=%q, job=%q", + i+1, len(d.Data), d.Params.Format, d.Params.JobName) + } + + return nil +} + +// generateTestPNG creates a temporary PNG test image with three +// horizontal colour bands (red, green, blue) and returns its path. +// The caller is responsible for removing the file after use. +func generateTestPNG() (string, error) { + const size = 300 + img := image.NewRGBA(image.Rect(0, 0, size, size)) + + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + switch { + case y < size/3: + img.Set(x, y, color.RGBA{R: 255, A: 255}) // red + case y < 2*size/3: + img.Set(x, y, color.RGBA{G: 255, A: 255}) // green + default: + img.Set(x, y, color.RGBA{B: 255, A: 255}) // blue + } + } + } + + f, err := os.CreateTemp("", "mfp-test-*.png") + if err != nil { + return "", err + } + defer f.Close() + + if err := png.Encode(f, img); err != nil { + os.Remove(f.Name()) + return "", err + } + + return f.Name(), nil +} diff --git a/cmd/mfp-test/test/cupsctl.go b/cmd/mfp-test/test/cupsctl.go new file mode 100644 index 00000000..71f56aba --- /dev/null +++ b/cmd/mfp-test/test/cupsctl.go @@ -0,0 +1,53 @@ +// MFP - Multi-Function Printers and scanners toolkit +// The "mfp-test" command +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// CUPS queue management + +package test + +import ( + "context" + "fmt" + "os/exec" +) + +// CreateCUPSQueue creates a CUPS printer queue named name, +// pointing to the virtual IPP printer at ippURL. +// +// It uses lpadmin to register the queue with the IPP Everywhere +// driver, which works with any standards-compliant IPP printer. +func CreateCUPSQueue(ctx context.Context, name, ippURL string) error { + cmd := exec.CommandContext(ctx, + "lpadmin", + "-p", name, + "-E", + "-v", ippURL, + "-m", "everywhere", + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lpadmin -p %s: %w: %s", name, err, out) + } + + return nil +} + +// RemoveCUPSQueue removes the CUPS printer queue named name. +// It is safe to call even if the queue does not exist. +func RemoveCUPSQueue(ctx context.Context, name string) error { + cmd := exec.CommandContext(ctx, + "lpadmin", + "-x", name, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("lpadmin -x %s: %w: %s", name, err, out) + } + + return nil +} diff --git a/cmd/mfp-test/test/doc.go b/cmd/mfp-test/test/doc.go new file mode 100644 index 00000000..13815b2f --- /dev/null +++ b/cmd/mfp-test/test/doc.go @@ -0,0 +1,12 @@ +// MFP - Multi-Function Printers and scanners toolkit +// The "mfp-test" command +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// Package documentation + +// Package test implements the print system testing pipeline. +// It provides document capture, CUPS queue management, and +// test result reporting for the mfp-test command. +package test