Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/mfp-test/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
SUBDIRS = test
CLEAN = mfp-test

include ../../Rules.mak
15 changes: 15 additions & 0 deletions cmd/mfp-test/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions cmd/mfp-test/test/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include ../../../Rules.mak
87 changes: 87 additions & 0 deletions cmd/mfp-test/test/capture.go
Original file line number Diff line number Diff line change
@@ -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
}
248 changes: 248 additions & 0 deletions cmd/mfp-test/test/command.go
Original file line number Diff line number Diff line change
@@ -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 <file>")
}

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
}
Loading