Files
yatm/e2e/cli_test.go
T
Samuel Cui 4fef653b71 Prepare YATM v1.0.0-alpha.2
Unify file queries and Scan execution, retain indexed Identical results,
improve Job and Preview workflows, and verify release packaging.
2026-10-05 19:08:49 +08:00

321 lines
12 KiB
Go

//go:build e2e
package e2e
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"sync"
"testing"
"github.com/improbable-eng/grpc-web/go/grpcweb"
"github.com/samuelncui/yatm/entity"
"github.com/samuelncui/yatm/internal/apis"
"github.com/samuelncui/yatm/internal/executor"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
var binaries struct {
sync.Once
directory string
err error
}
func TestMain(m *testing.M) {
// Remove only the build directory allocated by this test process.
code := m.Run()
if binaries.directory != "" {
if err := os.RemoveAll(binaries.directory); err != nil {
fmt.Fprintln(os.Stderr, err)
code = 1
}
}
os.Exit(code)
}
func testBinary(t *testing.T, name string) string {
t.Helper()
// Acceptance can exercise extracted release programs without rebuilding or owning their directory.
if directory := os.Getenv("YATM_E2E_BIN_DIR"); directory != "" {
path, err := candidateBinary(directory, name)
require.NoError(t, err)
return path
}
// Build the actual shipped entry points once for this acceptance process.
binaries.Do(func() {
binaries.directory, binaries.err = os.MkdirTemp("", "yatm-e2e-binaries-")
if binaries.err != nil {
return
}
for _, item := range []struct{ name, target string }{{"yatm-cli", "./cmd/yatm-cli"}, {"yatm-httpd", "./cmd/httpd"}} {
command := exec.Command("go", "build", "-o", filepath.Join(binaries.directory, item.name), item.target)
command.Dir = ".."
output, err := command.CombinedOutput()
if err != nil {
binaries.err = fmt.Errorf("build %s: %w\n%s", item.name, err, output)
return
}
}
})
require.NoError(t, binaries.err)
return filepath.Join(binaries.directory, name)
}
func candidateBinary(directory, name string) (string, error) {
// Resolve before child processes change their working directory to an isolated installation.
path, err := filepath.Abs(filepath.Join(directory, name))
if err != nil {
return "", fmt.Errorf("resolve candidate %s: %w", name, err)
}
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("inspect candidate %s: %w", name, err)
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
return "", fmt.Errorf("candidate %s must be a regular executable: %s", name, path)
}
return path, nil
}
func TestCandidateBinarySelection(t *testing.T) {
// A supplied candidate is used in place and never registered for TestMain cleanup.
directory := t.TempDir()
path := filepath.Join(directory, "yatm-cli")
require.NoError(t, os.WriteFile(path, []byte("candidate fixture"), 0o755))
t.Setenv("YATM_E2E_BIN_DIR", directory)
owned := binaries.directory
require.Equal(t, path, testBinary(t, "yatm-cli"))
require.Equal(t, owned, binaries.directory)
data, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, "candidate fixture", string(data))
// Broken candidates fail explicitly instead of silently testing a checkout build.
_, err = candidateBinary(directory, "yatm-httpd")
require.ErrorIs(t, err, os.ErrNotExist)
require.NoError(t, os.Chmod(path, 0o644))
_, err = candidateBinary(directory, "yatm-cli")
require.ErrorContains(t, err, "regular executable")
require.NoError(t, os.Mkdir(filepath.Join(directory, "yatm-httpd"), 0o755))
_, err = candidateBinary(directory, "yatm-httpd")
require.ErrorContains(t, err, "regular executable")
}
// cliConnection preserves typed test assertions while every business request runs a real CLI process.
// Unsupported requests fail; this adapter never falls back to direct RPC or private service calls.
type cliConnection struct {
binary string
url string
directory string
}
func serveCLI(t *testing.T, api *apis.API, exe *executor.Executor) *cliConnection {
t.Helper()
// Fault-injection fixtures retain server ownership, but use the production HTTP transport.
server := grpc.NewServer()
entity.RegisterJobServiceServer(server, api)
api.RegisterLocations(server)
exe.RegisterJobServices(server)
mux := http.NewServeMux()
mux.Handle("/services/", http.StripPrefix("/services", grpcweb.WrapServer(server)))
mux.Handle("/files/", http.StripPrefix("/files", api.Uploader()))
httpServer := httptest.NewServer(mux)
t.Cleanup(func() { httpServer.Close(); server.Stop() })
connection := &cliConnection{binary: testBinary(t, "yatm-cli"), url: httpServer.URL, directory: t.TempDir()}
return connection
}
func (c *cliConnection) run(ctx context.Context, args ...string) ([]byte, error) {
// Pass a deadline to the process and the network client; capture stderr independently of JSON.
command := exec.CommandContext(ctx, c.binary, append([]string{"--server", c.url, "--timeout", "2m"}, args...)...)
command.Dir = c.directory
var stderr bytes.Buffer
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
return output, fmt.Errorf("yatm-cli %q: %w\n%s", args, err, stderr.String())
}
return output, nil
}
func (c *cliConnection) Invoke(ctx context.Context, method string, request, reply any, _ ...grpc.CallOption) error {
// Translate the typed fixture request to a documented CLI command.
args, err := c.arguments(method, request)
if err != nil {
return err
}
output, err := c.run(ctx, args...)
if err != nil {
return err
}
message, ok := reply.(proto.Message)
if !ok {
return fmt.Errorf("CLI reply is not protobuf: %T", reply)
}
if err := decodeCLIOutput(output, message); err != nil {
return fmt.Errorf("decode CLI result for %s: %w\n%s", method, err, output)
}
return nil
}
func decodeCLIOutput(output []byte, message proto.Message) error {
// The detail CLI prints the inner projection instead of the unary RPC envelope.
if reply, ok := message.(*entity.GetFileResponse); ok {
reply.Detail = new(entity.FilesDetail)
return protojson.Unmarshal(output, reply.Detail)
}
// Job logs intentionally use readable UTF-8 text instead of protobuf's base64 bytes representation.
if reply, ok := message.(*entity.GetJobLogResponse); ok {
var value struct {
Logs string `json:"logs"`
Offset int64 `json:"offset,string"`
}
if err := json.Unmarshal(output, &value); err != nil {
return err
}
reply.Logs, reply.Offset = []byte(value.Logs), value.Offset
return nil
}
// A listing writes one message per batch, so its batches merge into the one reply a
// caller reads; every other command writes exactly one message.
if reply, ok := message.(*entity.ListFilesResponse); ok {
return decodeListBatches(output, reply)
}
return protojson.Unmarshal(output, message)
}
// decodeListBatches merges one listing's JSON Lines batches into a single reply: rows
// accumulate, and the directory facts its first batch states stay stated.
func decodeListBatches(output []byte, reply *entity.ListFilesResponse) error {
batches := 0
for _, line := range bytes.Split(output, []byte("\n")) {
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var batch entity.ListFilesResponse
if err := protojson.Unmarshal(line, &batch); err != nil {
return err
}
proto.Merge(reply, &batch)
batches++
}
if batches == 0 {
return fmt.Errorf("listing wrote no batch")
}
return nil
}
func (*cliConnection) NewStream(context.Context, *grpc.StreamDesc, string, ...grpc.CallOption) (grpc.ClientStream, error) {
return nil, fmt.Errorf("streaming RPC has no CLI acceptance adapter")
}
func decimal(value int64) string { return strconv.FormatInt(value, 10) }
func selectionArguments(selections []*entity.FileSelection) ([]string, error) {
// CLI selections preserve identity and scope; the server freezes and expands them.
var result []string
for _, selected := range selections {
switch value := selected.Target.(type) {
case *entity.FileSelection_Library:
result = append(result, "--file-id", decimal(value.Library.FileId))
if selected.Scope == entity.FileScope_FILE_SCOPE_SAVED {
result = append(result, "--scope", "saved")
}
case *entity.FileSelection_Location:
result = append(result, "--location", decimal(value.Location.LocationId)+":"+value.Location.Path)
default:
return nil, fmt.Errorf("unsupported selection %T", selected.Target)
}
}
return result, nil
}
func indexedSelections(t *testing.T, ctx context.Context, connection *cliConnection, root string, paths ...string) []*entity.FileSelection {
t.Helper()
selections := liveSelections(t, ctx, connection, root, paths...)
// Tests for content matching explicitly request analysis; ordinary content workflows do not require it.
job, err := entity.NewScanJobServiceClient(connection).Create(ctx, &entity.CreateScanJobRequest{Spec: &entity.ScanJobSpec{Selections: []*entity.FileSelection{{Target: &entity.FileSelection_Location{Location: &entity.LocationSelection{LocationId: selections[0].GetLocation().LocationId, Path: ""}}, Scope: entity.FileScope_FILE_SCOPE_ALL}}, SignaturePolicy: entity.ScanSignaturePolicy_SCAN_SIGNATURE_POLICY_KNOWN_ONLY, ResultPolicy: entity.ScanResultPolicy_SCAN_RESULT_POLICY_PUBLISH_ORIGINALS, PreviewPolicy: entity.PreviewPolicy_PREVIEW_POLICY_NONE}})
require.NoError(t, err)
_, err = connection.run(ctx, "job", "wait", decimal(job.Job.Id), "--wait-timeout", "2m", "--poll-interval", "100ms")
require.NoError(t, err)
return selections
}
func liveSelections(t *testing.T, ctx context.Context, connection *cliConnection, root string, paths ...string) []*entity.FileSelection {
t.Helper()
// Find or register one source using only user-visible Location commands.
root, err := filepath.EvalSymlinks(root)
require.NoError(t, err)
locations := entity.NewLocationServiceClient(connection)
var source *entity.Location
for after := int64(0); ; {
page, err := locations.List(ctx, &entity.ListLocationsRequest{AfterId: after, Limit: 100})
require.NoError(t, err)
for _, value := range page.Locations {
if value.RootPath == root {
source = value
}
}
if !page.HasMore {
break
}
after = page.Locations[len(page.Locations)-1].Id
}
if source == nil {
registered, err := locations.Create(ctx, &entity.CreateLocationRequest{Location: &entity.Location{Name: "Archive", RootPath: root}})
require.NoError(t, err)
source = registered.Location
}
// Select real paths without collection or content analysis.
result := make([]*entity.FileSelection, 0, len(paths))
for _, path := range paths {
result = append(result, &entity.FileSelection{Target: &entity.FileSelection_Location{Location: &entity.LocationSelection{LocationId: source.Id, Path: path}}})
}
return result
}
func librarySelections(ids ...int64) []*entity.FileSelection {
result := make([]*entity.FileSelection, 0, len(ids))
for _, id := range ids {
result = append(result, &entity.FileSelection{Target: &entity.FileSelection_Library{Library: &entity.LibrarySelection{FileId: id}}, Scope: entity.FileScope_FILE_SCOPE_ALL})
}
return result
}
func restoreDestination(t *testing.T, ctx context.Context, connection *cliConnection, root string) *entity.RestoreDestination {
t.Helper()
// Restore-only directories do not need a source scan.
require.NoError(t, os.MkdirAll(root, 0o755))
root, err := filepath.EvalSymlinks(root)
require.NoError(t, err)
locations := entity.NewLocationServiceClient(connection)
for after := int64(0); ; {
page, err := locations.List(ctx, &entity.ListLocationsRequest{AfterId: after, Limit: 100})
require.NoError(t, err)
for _, value := range page.Locations {
if value.RootPath == root && value.RestoreTarget {
return &entity.RestoreDestination{LocationId: value.Id}
}
}
if !page.HasMore {
break
}
after = page.Locations[len(page.Locations)-1].Id
}
created, err := locations.Create(ctx, &entity.CreateLocationRequest{Location: &entity.Location{Name: "Restore", RootPath: root, RestoreTarget: true}})
require.NoError(t, err)
return &entity.RestoreDestination{LocationId: created.Location.Id}
}