diff --git a/cmd/uatu/main.go b/cmd/uatu/main.go new file mode 100644 index 0000000..e5b42b4 --- /dev/null +++ b/cmd/uatu/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "time" +) + +type testOptions struct { + spec string + bundleID string + platform string + avd string + duration time.Duration + seed int64 + output string +} + +const topUsage = `uatu is a property-based UI fuzzer for mobile apps. + +Usage: + uatu [flags] + +Commands: + test Run a spec against an app for a fixed duration. + doctor Check that the host environment is ready to run uatu. + +Run "uatu -h" for command-specific flags. +` + +func parseTestArgs(args []string, stderr io.Writer) (testOptions, error) { + flagSet := flag.NewFlagSet("test", flag.ContinueOnError) + flagSet.SetOutput(stderr) + var options testOptions + flagSet.StringVar(&options.spec, "spec", "", "path to the TypeScript spec (required)") + flagSet.StringVar(&options.bundleID, "bundle-id", "", "target app bundle ID (required)") + flagSet.StringVar(&options.platform, "platform", "android", "target platform: android (ios deferred)") + flagSet.StringVar(&options.avd, "avd", "", "Android AVD name (required on android)") + flagSet.DurationVar(&options.duration, "duration", 5*time.Minute, "total test duration") + flagSet.Int64Var(&options.seed, "seed", 0, "RNG seed (0 = random)") + flagSet.StringVar(&options.output, "output", "./runs", "output directory for traces") + if err := flagSet.Parse(args); err != nil { + return testOptions{}, err + } + if options.spec == "" { + return testOptions{}, errors.New("--spec is required") + } + if options.bundleID == "" { + return testOptions{}, errors.New("--bundle-id is required") + } + if options.platform == "android" && options.avd == "" { + return testOptions{}, errors.New("--avd is required on android") + } + if options.platform != "android" { + return testOptions{}, fmt.Errorf("unsupported platform: %q (only android in v0.1)", options.platform) + } + return options, nil +} + +func runTest(options testOptions, stdout io.Writer) error { + fmt.Fprintf(stdout, "uatu test (stub): %+v\n", options) + return nil +} + +func runDoctor(stdout io.Writer) error { + fmt.Fprintln(stdout, "uatu doctor (stub): all checks pending implementation") + return nil +} + +func run(args []string, stdout, stderr io.Writer) error { + if len(args) < 2 || args[1] == "-h" || args[1] == "--help" || args[1] == "help" { + fmt.Fprint(stdout, topUsage) + return nil + } + switch args[1] { + case "test": + options, err := parseTestArgs(args[2:], stderr) + if err != nil { + return err + } + return runTest(options, stdout) + case "doctor": + return runDoctor(stdout) + default: + return fmt.Errorf("unknown command: %q (try 'uatu help')", args[1]) + } +} + +func main() { + if err := run(os.Args, os.Stdout, os.Stderr); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/uatu/main_test.go b/cmd/uatu/main_test.go new file mode 100644 index 0000000..d3cc76f --- /dev/null +++ b/cmd/uatu/main_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "bytes" + "io" + "strings" + "testing" + "time" +) + +func TestParseTestArgs_Defaults(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--avd", "Pixel_5_API_33", + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if options.spec != "s.ts" || options.bundleID != "com.example" { + t.Errorf("unexpected options: %+v", options) + } + if options.platform != "android" { + t.Errorf("platform default: got %q, want android", options.platform) + } + if options.duration != 5*time.Minute { + t.Errorf("duration default: got %v, want 5m", options.duration) + } + if options.output != "./runs" { + t.Errorf("output default: got %q, want ./runs", options.output) + } + if options.seed != 0 { + t.Errorf("seed default: got %d, want 0", options.seed) + } +} + +func TestParseTestArgs_AllFlags(t *testing.T) { + options, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--platform", "android", + "--avd", "Pixel_5_API_33", + "--duration", "10m", + "--seed", "42", + "--output", "./out", + }, io.Discard) + if err != nil { + t.Fatal(err) + } + if options.avd != "Pixel_5_API_33" || options.duration != 10*time.Minute || options.seed != 42 || options.output != "./out" { + t.Errorf("unexpected options: %+v", options) + } +} + +func TestParseTestArgs_RequiresSpec(t *testing.T) { + _, err := parseTestArgs([]string{"--bundle-id", "com.example", "--avd", "x"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "--spec") { + t.Fatalf("expected missing --spec error, got %v", err) + } +} + +func TestParseTestArgs_RequiresBundleID(t *testing.T) { + _, err := parseTestArgs([]string{"--spec", "s.ts", "--avd", "x"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "--bundle-id") { + t.Fatalf("expected missing --bundle-id error, got %v", err) + } +} + +func TestParseTestArgs_RequiresAVDOnAndroid(t *testing.T) { + _, err := parseTestArgs([]string{"--spec", "s.ts", "--bundle-id", "com.example"}, io.Discard) + if err == nil || !strings.Contains(err.Error(), "--avd") { + t.Fatalf("expected missing --avd error, got %v", err) + } +} + +func TestParseTestArgs_RejectsNonAndroidPlatform(t *testing.T) { + _, err := parseTestArgs([]string{ + "--spec", "s.ts", + "--bundle-id", "com.example", + "--platform", "ios", + "--avd", "x", + }, io.Discard) + if err == nil || !strings.Contains(err.Error(), "unsupported platform") { + t.Fatalf("expected unsupported-platform error, got %v", err) + } +} + +func TestRun_HelpPrintsUsage(t *testing.T) { + var stdout bytes.Buffer + if err := run([]string{"uatu"}, &stdout, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "uatu ") { + t.Errorf("usage missing, got: %q", stdout.String()) + } +} + +func TestRun_UnknownCommand(t *testing.T) { + err := run([]string{"uatu", "wat"}, io.Discard, io.Discard) + if err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Fatalf("expected unknown-command error, got %v", err) + } +} + +func TestRun_Doctor(t *testing.T) { + var stdout bytes.Buffer + if err := run([]string{"uatu", "doctor"}, &stdout, io.Discard); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "doctor") { + t.Errorf("doctor output missing, got: %q", stdout.String()) + } +} + +func TestRun_TestSubcommand(t *testing.T) { + var stdout bytes.Buffer + err := run([]string{ + "uatu", "test", + "--spec", "s.ts", + "--bundle-id", "com.example", + "--avd", "Pixel_5_API_33", + }, &stdout, io.Discard) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "uatu test (stub)") { + t.Errorf("test stub output missing, got: %q", stdout.String()) + } +}