package cli import ( "bytes" "encoding/csv" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "runtime/debug" "strings" "testing" "testing/fstest" ) // shipped holds the shipped modules as read from the checkout, each folder under ../data // holding a manifest, so these tests link no data package; cmd/fejkdata runs over the // packages themselves. var shipped = func() []fs.FS { var modules []fs.FS err := filepath.WalkDir(filepath.Join("..", "data"), func(path string, d fs.DirEntry, err error) error { if err == nil && d.Name() == ".fejkdata.json" { modules = append(modules, os.DirFS(filepath.Dir(path))) } return err }) if err != nil || len(modules) == 0 { panic(fmt.Sprintf("no shipped module found under ../data: %v", err)) } return modules }() // svSE, enUS and misc are copies of a shipped module's files without its manifest, so a test // layers its categories unprefixed with -d. var svSE, enUS, misc string func TestMain(m *testing.M) { tmp, err := os.MkdirTemp("", "fejkdata-cli-test") if err != nil { panic(err) } svSE, enUS, misc = bareCopy(tmp, "sv_SE"), bareCopy(tmp, "en_US"), bareCopy(tmp, "misc") code := m.Run() os.RemoveAll(tmp) os.Exit(code) } // bareCopy copies the category and rows files of the shipped module in data/ into a // folder under tmp, and returns that folder. func bareCopy(tmp, module string) string { src, dst := filepath.Join("..", "data", module), filepath.Join(tmp, module) if err := os.Mkdir(dst, 0o755); err != nil { panic(err) } for _, pattern := range []string{"*.json", "*.tsv"} { files, err := filepath.Glob(filepath.Join(src, pattern)) if err != nil { panic(err) } for _, f := range files { if strings.HasPrefix(filepath.Base(f), ".") { continue } b, err := os.ReadFile(f) if err == nil { err = os.WriteFile(filepath.Join(dst, filepath.Base(f)), b, 0o644) } if err != nil { panic(err) } } } return dst } func runOut(stdin string, args ...string) (int, string, string) { var out, errb bytes.Buffer code := run("fejkdata", shipped, args, strings.NewReader(stdin), &out, &errb) return code, out.String(), errb.String() } // runTerminal runs as from a shell with nothing piped in. func runTerminal(args ...string) (int, string, string) { var out, errb bytes.Buffer code := run("fejkdata", shipped, args, nil, &out, &errb) return code, out.String(), errb.String() } func TestRunLoadsTheModulesItIsGiven(t *testing.T) { module := fstest.MapFS{"pet.json": {Data: []byte(`{"format":"{name}","name":["Fido"]}`)}} var out, errb bytes.Buffer if code := run("fejkdata", []fs.FS{module}, nil, strings.NewReader("{/pet}"), &out, &errb); code != 0 || out.String() != "Fido" { t.Errorf("run over a module holding pet = %d, %q, %q, want Fido", code, out.String(), errb.String()) } out.Reset() if code := run("fejkdata", []fs.FS{module}, []string{"--list"}, nil, &out, &errb); code != 0 || out.String() != "pet\npet.name\n" { t.Errorf("--list over a module holding pet = %d, %q, want pet and pet.name only", code, out.String()) } } func TestRunOutputsValue(t *testing.T) { code, out, errb := runOut("{/person}", "--data-path", svSE) if code != 0 { t.Fatalf("run = %d, stderr=%q", code, errb) } if out == "" || strings.Contains(out, "\n") { t.Fatalf("output = %q, stderr=%q; want one value and nothing added", out, errb) } } func TestRunDotPath(t *testing.T) { code, full, _ := runOut("{/person}", "--seed", "7", "--data-path", svSE) if code != 0 { t.Fatalf("person run = %d", code) } code, last, errb := runOut("{/person.last}", "--seed", "7", "--data-path", svSE) if code != 0 { t.Fatalf("person.last run = %d, stderr=%q", code, errb) } if strings.TrimSpace(last) == "" { t.Fatal("empty person.last output") } if last == full { t.Errorf("person.last %q should differ from person %q", last, full) } } func TestRunSeedSpellings(t *testing.T) { _, want, _ := runOut("{/address}", "--seed", "42", "--data-path", svSE) for _, args := range [][]string{ {"--seed=42", "--data-path", svSE}, {"-s", "42", "-d", svSE}, {"--data-path", svSE, "--seed", "42"}, } { code, got, errb := runOut("{/address}", args...) if code != 0 { t.Fatalf("run(%v) = %d, stderr=%q", args, code, errb) } if got != want { t.Errorf("run(%v) = %q, want %q", args, got, want) } } } func TestRunShortFlagValues(t *testing.T) { _, want, _ := runOut("{/address}", "--seed", "42", "--data-path", svSE) for _, args := range [][]string{ {"-s42", "-d", svSE}, {"-s", "42", "-d" + svSE}, {"-d", svSE, "-s42"}, } { code, got, errb := runOut("{/address}", args...) if code != 0 { t.Fatalf("run(%v) = %d, stderr=%q", args, code, errb) } if got != want { t.Errorf("run(%v) = %q, want %q", args, got, want) } } _, three, _ := runOut("{/word}\n", "-s", "1", "-n3", "-d", svSE) if lines := strings.Split(strings.TrimRight(three, "\n"), "\n"); len(lines) != 3 { t.Errorf("-n3 gave %d lines: %q", len(lines), three) } for _, c := range []struct { args []string want string }{ {[]string{"-s=42", "-d", svSE}, "-s42"}, {[]string{"-s=42", "-d", svSE}, "--seed=42"}, {[]string{"-d=" + svSE}, "--data-path="}, {[]string{"-nd", "3", "-d", svSE}, `--repeat needs an integer in 0..1048576, got "d"`}, {[]string{"--seed=", "-d", svSE}, `--seed needs an unsigned integer, got ""`}, } { code, out, errb := runOut("{/word}", c.args...) if code != 2 || out != "" { t.Errorf("run(%v) = %d, stdout %q, want misuse", c.args, code, out) } if !strings.Contains(errb, c.want) { t.Errorf("run(%v) stderr = %q, want %q", c.args, errb, c.want) } } code, out, _ := runOut("", "-hd", svSE) if code != 0 || !strings.Contains(out, "Usage") { t.Errorf("-hd (bundled help) = %d, %q, want usage", code, out) } } func TestRunDoubleDashEndsFlags(t *testing.T) { code, out, errb := runOut("{/person}", "--data-path", svSE, "--") if code != 0 || strings.TrimSpace(out) == "" { t.Fatalf("run = %d, out=%q, stderr=%q", code, out, errb) } code, _, errb = runOut("{/person}", "--data-path", svSE, "--", "--list") if code != 2 || !strings.Contains(errb, "stdin") { t.Errorf("after --, --list should be an argument: code %d, stderr %q", code, errb) } } func TestRunSingleDashLongIsRejected(t *testing.T) { for _, c := range []struct { args []string want string }{ {[]string{"-seed", "42", "-d", svSE}, "use --seed"}, {[]string{"-seed=42", "-d", svSE}, "use --seed"}, {[]string{"-data-path", svSE}, "use --data-path"}, {[]string{"-list", "-d", svSE}, "use --list"}, {[]string{"--nope", "-d", svSE}, "unknown flag --nope"}, {[]string{"-x", "-d", svSE}, "unknown flag -x"}, } { code, _, errb := runOut("{/person}", c.args...) if code != 2 { t.Errorf("run(%v) = %d, want 2", c.args, code) } if !strings.Contains(errb, c.want) { t.Errorf("run(%v) stderr = %q, want %q", c.args, errb, c.want) } } } func TestRunFlagValues(t *testing.T) { for _, c := range []struct { args []string want string }{ {[]string{"--seed", "-d", svSE}, `--seed needs an unsigned integer, got "-d"`}, {[]string{"-d", svSE, "--seed"}, "--seed needs a value"}, {[]string{"--seed", "x", "-d", svSE}, "--seed"}, {[]string{"--list=1", "-d", svSE}, "--list takes no value"}, {[]string{"--repeat", "-1", "-d", svSE}, "--repeat"}, {[]string{"-n", "-1", "-d", svSE}, "--repeat"}, } { code, _, errb := runOut("{/person}", c.args...) if code != 2 { t.Errorf("run(%v) = %d, want 2", c.args, code) } if !strings.Contains(errb, c.want) { t.Errorf("run(%v) stderr = %q, want %q", c.args, errb, c.want) } } } func TestRunRepeat(t *testing.T) { code, out, errb := runOut("{/word}\n", "--seed", "1", "--repeat", "3", "--data-path", svSE) if code != 0 { t.Fatalf("run = %d, stderr=%q", code, errb) } lines := strings.Split(strings.TrimRight(out, "\n"), "\n") if len(lines) != 3 { t.Errorf("repeat=3 gave %d lines: %q", len(lines), out) } _, short, _ := runOut("{/word}\n", "--seed", "1", "-n", "3", "-d", svSE) if short != out { t.Errorf("-n 3 = %q, want the same as --repeat 3 %q", short, out) } } func TestRunSeparator(t *testing.T) { code, out, errb := runOut("{/word}", "--repeat", "3", "--separator", ",", "--data-path", svSE) if code != 0 { t.Fatalf("run = %d, stderr=%q", code, errb) } if n := strings.Count(out, "\n"); n != 0 { t.Errorf("want no newline, got %d: %q", n, out) } if !strings.Contains(out, ",") { t.Errorf("values should be comma-joined: %q", out) } } func TestRunRepeatAdvancesRNG(t *testing.T) { code, out, errb := runOut("{/person}\n", "--seed", "1", "--repeat", "5", "--data-path", svSE) if code != 0 { t.Fatalf("run = %d, stderr=%q", code, errb) } uniq := map[string]bool{} for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n") { uniq[l] = true } if len(uniq) < 2 { t.Errorf("repeat should vary output, all identical: %q", out) } } func TestRunMisuse(t *testing.T) { for _, c := range []struct { stdin string args []string }{ {"{/person}", []string{"-d", svSE, "word"}}, {"", []string{"-d", svSE, "--list", "person"}}, {"{/sv_SE.person}", []string{"--no-shipped-data"}}, } { code, out, errb := runOut(c.stdin, c.args...) if code != 2 { t.Errorf("run(%q, %v) = %d, want 2", c.stdin, c.args, code) } if !strings.Contains(errb, "try 'fejkdata --help'") { t.Errorf("run(%q, %v) stderr = %q, want a pointer to --help", c.stdin, c.args, errb) } if out != "" { t.Errorf("run(%q, %v) stdout = %q, want nothing", c.stdin, c.args, out) } } } func TestRunTakesNoOperand(t *testing.T) { file := filepath.Join(t.TempDir(), "users.tmpl") if err := os.WriteFile(file, []byte("{/sv_SE.person}"), 0o644); err != nil { t.Fatal(err) } for _, c := range []struct { args []string want string }{ {[]string{"sv_SE.person"}, "echo '{/sv_SE.person}' | fejkdata"}, {[]string{"/en_US.address"}, "echo '{/en_US.address}' | fejkdata"}, {[]string{"{/en_US.person}"}, "echo '{/en_US.person}' | fejkdata"}, {[]string{"name: {/sv_SE.person.last}"}, "echo 'name: {/sv_SE.person.last}' | fejkdata"}, {[]string{"geo.US.locality[O'Fallon].name"}, `echo '{/geo.US.locality[O'\''Fallon].name}' | fejkdata`}, {[]string{"en_US.address"}, "echo '{/en_US.address}' | fejkdata"}, {[]string{`C:\temp {/sv_SE.person}`}, `printf '%s' 'C:\temp {/sv_SE.person}' | fejkdata`}, {[]string{"-"}, "drop it"}, {[]string{file}, "fejkdata < " + file}, } { code, out, errb := runOut("", c.args...) if code != 2 || out != "" || !strings.Contains(errb, "unexpected argument") || !strings.Contains(errb, c.want) { t.Errorf("run(%v) = %d, %q, %q; want misuse naming %q", c.args, code, out, errb, c.want) } } } type failingReader struct{} func (failingReader) Read([]byte) (int, error) { return 0, errors.New("bad file descriptor") } func TestRunUnreadableStdinIsMisuse(t *testing.T) { var out, errb bytes.Buffer if code := run("fejkdata", shipped, nil, failingReader{}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "cannot be read") { t.Errorf("run with an unreadable stdin = %d, %q; want misuse naming it", code, errb.String()) } } func TestRunTerminalStdinIsMisuse(t *testing.T) { code, out, errb := runTerminal("--seed", "1") if code != 2 || out != "" || !strings.Contains(errb, "| fejkdata") { t.Errorf("run with nothing piped = %d, %q, %q; want misuse showing a template piped to fejkdata", code, out, errb) } for _, args := range [][]string{{"--list"}, {"--help"}, {"--version"}} { if code, out, errb := runTerminal(args...); code != 0 || out == "" { t.Errorf("run(%v) with nothing piped = %d, %q, %q; want it to run, reading no stdin", args, code, out, errb) } } } func TestRunRendersExactlyWhatIsSent(t *testing.T) { for stdin, want := range map[string]string{ "hihi": "hihi", "hihi\n": "hihi\n", "hihi\r\n": "hihi\r\n", "hihi\n\n": "hihi\n\n", "{{x}}\n": "{x}\n", "\"a\"\n": "a", " \t\"a\"\r\n\n": "a", "{/misc.territory[SE].alpha2}": "SE", "{/misc.territory[SE].alpha2}\n": "SE\n", } { code, out, errb := runOut(stdin, "--seed", "1") if code != 0 || out != want { t.Errorf("run(%q) = %d, %q, %q; want %q", stdin, code, out, errb, want) } } if code, out, errb := runOut("{\"format\":\"{x}\\n\",\"x\":[\"a\",\"b\"]}\n"); code != 0 || (out != "a\n" && out != "b\n") { t.Errorf("a JSON template's format = %d, %q, %q; want its own newline only", code, out, errb) } if code, out, errb := runOut("{/sv_SE.word}\n", "--seed", "1", "-n", "3", "--separator", ","); code != 0 || strings.Count(out, "\n,") != 2 || !strings.HasSuffix(out, "\n") { t.Errorf("echo's newline and a separator = %d, %q, %q; want each render's newline, then the separator", code, out, errb) } } func TestRunRendersBlankStdin(t *testing.T) { for _, stdin := range []string{"", "\n", " \r\n\t"} { if code, out, errb := runOut(stdin); code != 0 || out != stdin { t.Errorf("run(%q) = %d, %q, %q; want the blank template rendered as it is", stdin, code, out, errb) } } } func TestRunTextPrintsAsWritten(t *testing.T) { for _, stdin := range []string{"sv_SE.person", "[Skåne län]", "x[1]y", `say "hi"`} { code, out, errb := runOut(stdin) if code != 0 || out != stdin { t.Errorf("run(%q) = %d, %q, %q; want the text as written", stdin, code, out, errb) } } } func TestRunMultipleDataPaths(t *testing.T) { code, out, errb := runOut("{/person}", "-d", enUS, "--data-path", svSE) if code != 0 { t.Fatalf("run(multi-dir) = %d, stderr=%q", code, errb) } if strings.TrimSpace(out) == "" { t.Fatal("empty output for multi-dir run") } } func TestRunUnknownCategoryFails(t *testing.T) { code, _, errb := runOut("{/nope}", "--data-path", svSE, "--format", "csv") if code != 1 { t.Fatalf("run = %d, want 1", code) } if !strings.Contains(errb, "nope") { t.Errorf("stderr %q should name the unknown category", errb) } } func TestRunList(t *testing.T) { code, out, errb := runOut("", "--data-path", svSE, "--list") if code != 0 { t.Fatalf("run = %d, stderr=%q", code, errb) } for _, want := range []string{"person", "person.last", "address"} { if !strings.Contains(out, want) { t.Errorf("list output missing %q:\n%s", want, out) } } } func TestRunHelpOnStdout(t *testing.T) { for _, h := range []string{"-h", "--help"} { code, out, errb := runOut("", h) if code != 0 { t.Errorf("%s = %d, want 0", h, code) } if !strings.Contains(out, "Usage") || errb != "" { t.Errorf("%s: stdout = %q, stderr = %q, want usage on stdout only", h, out, errb) } } } func TestVersionLineNamesFejkdatasVersion(t *testing.T) { fejkdata := func(version string, replace *debug.Module) *debug.Module { return &debug.Module{Path: "github.com/larvit/fejkdata", Version: version, Replace: replace} } mycli := debug.Module{Path: "example.com/mycli", Version: "v9.0.0"} for _, c := range []struct { info *debug.BuildInfo want string }{ {&debug.BuildInfo{Main: *fejkdata("v0.1.0", nil)}, "fejkdata v0.1.0"}, {&debug.BuildInfo{Main: *fejkdata("(devel)", nil)}, "fejkdata devel"}, {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", nil)}}, "mycli v9.0.0, fejkdata v0.1.0"}, {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", &debug.Module{Path: "../fejkdata"})}}, "mycli v9.0.0, fejkdata devel"}, {&debug.BuildInfo{Main: mycli, Deps: []*debug.Module{fejkdata("v0.1.0", &debug.Module{Path: "example.com/fork", Version: "v0.1.1"})}}, "mycli v9.0.0, fejkdata v0.1.1"}, {nil, "fejkdata devel"}, } { if got := versionLine("mycli", c.info); got != c.want { t.Errorf("versionLine(mycli, %+v) = %q, want %q", c.info, got, c.want) } } } func TestRunNamesItselfByTheCommandName(t *testing.T) { var out, errb bytes.Buffer if code := run("mycli", shipped, []string{"--nope"}, nil, &out, &errb); code != 2 || errb.String() != "mycli: unknown flag --nope\ntry 'mycli --help'\n" { t.Errorf("mycli --nope = %d, %q", code, errb.String()) } errb.Reset() if code := run("mycli", shipped, nil, nil, &out, &errb); code != 2 || !strings.Contains(errb.String(), "| mycli\n") { t.Errorf("mycli with nothing piped = %d, %q, want a pipe into mycli", code, errb.String()) } if code := run("mycli", shipped, []string{"--help"}, nil, &out, &errb); code != 0 || !strings.HasPrefix(out.String(), "Usage: