diff --git a/cli/cmd/file.go b/cli/cmd/file.go new file mode 100644 index 000000000..32adfaf1e --- /dev/null +++ b/cli/cmd/file.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/raids-lab/crater/cli/internal/api" + "github.com/raids-lab/crater/cli/internal/completion" + "github.com/raids-lab/crater/cli/internal/i18n" + "github.com/raids-lab/crater/cli/internal/output" + "github.com/raids-lab/crater/cli/pkg/errorcodes" + "github.com/spf13/cobra" +) + +var fileRemoteRoots = []string{"user", "public", "account"} + +var fileCmd = &cobra.Command{ + Use: "file", + Short: "View remote files", + Long: "List files in user, public, and account storage spaces.", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errUnknownSubcommand(cmd, args[0]) + } + return cmd.Help() + }, +} + +var fileLsCmd = &cobra.Command{ + Use: "ls [remote-path]", + Short: "List remote files", + Args: maxOneArg, + RunE: runFileLs, +} + +func runFileLs(_ *cobra.Command, args []string) error { + remotePath := "" + if len(args) == 1 { + remotePath = args[0] + } + normalizedPath, err := normalizeRemotePath(remotePath, true) + if err != nil { + return err + } + + client, err := activeAPIClient() + if err != nil { + return err + } + files, err := client.ListFiles(normalizedPath) + if err != nil { + return cliErrFromAPI(err) + } + sortFileInfos(files) + + if outputJSON { + return output.WriteSuccessJSON(os.Stdout, output.SuccessEnvelope(map[string]interface{}{ + "files": files, + })) + } + printFileTable(files) + return nil +} + +func normalizeRemotePath(remotePath string, allowEmpty bool) (string, error) { + if remotePath == "" { + if allowEmpty { + return "", nil + } + return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath)) + } + if strings.ContainsRune(remotePath, '\\') { + return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath)) + } + for _, character := range remotePath { + if unicode.IsControl(character) { + return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath)) + } + } + + normalized := strings.Trim(remotePath, "/") + if normalized == "" { + if allowEmpty { + return "", nil + } + return "", invalidRemotePathIssue(i18n.T("err_file_path_empty")) + } + rawSegments := strings.Split(normalized, "/") + segments := make([]string, 0, len(rawSegments)) + for _, segment := range rawSegments { + if segment == ".." { + return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath)) + } + if segment == "" || segment == "." { + continue + } + segments = append(segments, segment) + } + if len(segments) == 0 { + if allowEmpty { + return "", nil + } + return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath)) + } + if !isFileRemoteRoot(segments[0]) { + return "", invalidRemotePathIssue(i18n.T("err_file_path_root", remotePath)) + } + return strings.Join(segments, "/"), nil +} + +func invalidRemotePathIssue(message string) error { + return errUsageFromIssues([]usageIssue{{ + Code: errorcodes.ErrInvalidFlagValue, + Message: message, + Field: "remote-path", + }}) +} + +func isFileRemoteRoot(value string) bool { + for _, root := range fileRemoteRoots { + if value == root { + return true + } + } + return false +} + +func sortFileInfos(files []api.FileInfo) { + sort.SliceStable(files, func(left, right int) bool { + if files[left].IsDir != files[right].IsDir { + return files[left].IsDir + } + leftName := strings.ToLower(files[left].Name) + rightName := strings.ToLower(files[right].Name) + if leftName == rightName { + return files[left].Name < files[right].Name + } + return leftName < rightName + }) +} + +func printFileTable(files []api.FileInfo) { + fmt.Printf("%s %s %s %s\n", + i18n.PadRight(i18n.T("table_name"), 36), + i18n.PadRight(i18n.T("table_type"), 12), + i18n.PadRight(i18n.T("file_table_size"), 14), + i18n.PadRight(i18n.T("file_table_modified"), 22)) + for _, file := range files { + fileType := i18n.T("file_type_regular") + size := strconv.FormatInt(file.Size, 10) + if file.IsDir { + fileType = i18n.T("file_type_directory") + size = "-" + } + modified := "-" + if !file.ModifyTime.IsZero() { + modified = file.ModifyTime.Format("2006-01-02 15:04:05") + } + fmt.Printf("%s %s %s %s\n", + i18n.PadRight(displayFileName(file.Name), 36), + i18n.PadRight(fileType, 12), + i18n.PadRight(size, 14), + i18n.PadRight(modified, 22)) + } +} + +func displayFileName(name string) string { + for _, character := range name { + if unicode.IsControl(character) { + return strconv.QuoteToGraphic(name) + } + } + return name +} + +func fileRootCompleter(ctx completion.Context) ([]completion.Candidate, error) { + prefix := strings.ToLower(completion.CurrentWordPrefix(ctx)) + candidates := make([]completion.Candidate, 0, len(fileRemoteRoots)) + for _, root := range fileRemoteRoots { + value := root + if prefix != "" && !strings.HasPrefix(value, prefix) { + continue + } + candidates = append(candidates, completion.Candidate{ + Value: value, + Description: i18n.T("file_root_" + root + "_desc"), + }) + } + return candidates, nil +} + +func init() { + fileCmd.AddCommand(fileLsCmd) + rootCmd.AddCommand(fileCmd) + completion.RegisterPositional([]string{"file", "ls"}, 0, fileRootCompleter) +} diff --git a/cli/cmd/file_test.go b/cli/cmd/file_test.go new file mode 100644 index 000000000..7739aedde --- /dev/null +++ b/cli/cmd/file_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/raids-lab/crater/cli/internal/api" + "github.com/raids-lab/crater/cli/internal/completion" +) + +func TestNormalizeRemotePath(t *testing.T) { + tests := []struct { + name string + input string + allowEmpty bool + want string + wantErr bool + }{ + {name: "visible root", allowEmpty: true, want: ""}, + {name: "logical root", input: "user", want: "user"}, + {name: "leading and trailing slash", input: "/public/实验 data/", want: "public/实验 data"}, + {name: "account nested", input: "account/projects/run #1", want: "account/projects/run #1"}, + {name: "empty rejected", input: "", wantErr: true}, + {name: "unknown root", input: "admin/secret", wantErr: true}, + {name: "parent traversal", input: "user/../public", wantErr: true}, + {name: "current segment normalized", input: "user/./file", want: "user/file"}, + {name: "duplicate slash normalized", input: "user//file", want: "user/file"}, + {name: "backslash", input: `user\file`, wantErr: true}, + {name: "control byte", input: "user/file\nname", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := normalizeRemotePath(test.input, test.allowEmpty) + if (err != nil) != test.wantErr { + t.Fatalf("normalizeRemotePath(%q) error = %v, wantErr %v", test.input, err, test.wantErr) + } + if got != test.want { + t.Fatalf("normalizeRemotePath(%q) = %q, want %q", test.input, got, test.want) + } + }) + } +} + +func TestFileRootCompleterUsesStaticLogicalRoots(t *testing.T) { + candidates, err := fileRootCompleter(completion.Context{ + Words: []string{"crater", "file", "ls", "u"}, + Current: 4, + }) + if err != nil { + t.Fatalf("fileRootCompleter: %v", err) + } + if len(candidates) != 1 || candidates[0].Value != "user" { + t.Fatalf("candidates = %#v, want user", candidates) + } +} + +func TestSortFileInfosDirectoriesFirstThenName(t *testing.T) { + files := []api.FileInfo{ + {Name: "z.bin", Size: 1}, + {Name: "beta", IsDir: true}, + {Name: "Alpha.txt", Size: 2}, + {Name: "alpha", IsDir: true}, + } + sortFileInfos(files) + + got := []string{files[0].Name, files[1].Name, files[2].Name, files[3].Name} + want := []string{"alpha", "beta", "Alpha.txt", "z.bin"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("sorted names = %#v, want %#v", got, want) + } +} + +func TestSortFileInfosKeepsEmptySliceStable(t *testing.T) { + files := []api.FileInfo{} + sortFileInfos(files) + if files == nil || len(files) != 0 { + t.Fatalf("files = %#v, want non-nil empty slice", files) + } +} + +func TestDisplayFileNameEscapesTerminalControlCharacters(t *testing.T) { + if got := displayFileName("safe 文件.txt"); got != "safe 文件.txt" { + t.Fatalf("safe name = %q", got) + } + if got := displayFileName("line\nname"); got != `"line\nname"` { + t.Fatalf("control-character name = %q", got) + } + if got := displayFileName("color\x1b[31m"); got != `"color\x1b[31m"` { + t.Fatalf("escape-sequence name = %q", got) + } +} diff --git a/cli/docs/COMMANDS.md b/cli/docs/COMMANDS.md index 4b75a41c3..911d78b68 100644 --- a/cli/docs/COMMANDS.md +++ b/cli/docs/COMMANDS.md @@ -762,7 +762,7 @@ This section records the read-only API surface covered by the CLI after the broa - Sensitive credential reads (`/token`, `/secret`, Harbor credential APIs) are not exposed in the broad read surface. - WebSocket, terminal, and log streaming endpoints are not exposed because they are interactive/streaming rather than stable one-shot reads. - The untracked local `inference-services` API is not documented here until that backend/frontend feature lands in the branch base. -- Public health, Swagger, Prometheus metrics, and low-level WebDAV file listing are left to their domain-specific tools rather than this first read CLI pass. +- Public health, Swagger, Prometheus metrics, and generic WebDAV operations are left to their domain-specific tools rather than this read CLI surface. ### Admin-Only Read Coverage - `crater admin system-config llm|gpu-analysis|prequeue`: `/api/v1/admin/system-config/{llm,gpu-analysis,prequeue}`. @@ -772,3 +772,28 @@ This section records the read-only API surface covered by the CLI after the broa - `crater admin cronjobs`: `/api/v1/admin/operations/cronjob`. - `crater admin whitelist`: `/api/v1/admin/operations/whitelist`. - These commands surface existing admin GET APIs only. They do not perform update/delete/reconcile actions. + +--- + +## 8. 远端文件模块 (file) + +本模块面向普通用户访问 storage service 暴露的逻辑文件空间。远端路径不是本机路径,只允许以 `user`、`public` 或 `account` 为首段;CLI 会规范化安全的 `.`、重复分隔符和首尾分隔符,逐段进行 URL 编码,保留合法的空格与非 ASCII 文件名,并在请求前拒绝任何 `..` 段、反斜杠和控制字符。 + +### `crater file ls [remote-path]` + +- **描述**:列出当前用户可见的远端文件或目录。 +- **位置参数**: + - `[remote-path]`(可选):逻辑远端目录。省略时列出可见根目录;可用根为 `user`、`public`、`account`。 +- **处理逻辑**: + - 调用 `GET /api/ss/files` 或 `GET /api/ss/files/*path`。 + - 目录排在普通文件之前,同类型条目按名称稳定排序。 + - 空目录返回稳定的空列表。 + - 本命令只读取普通用户文件视图,不会切换到管理员接口。 +- **输出格式**: + - 默认模式:表格展示 `NAME`、`TYPE`、`SIZE`、`MODIFIED`;目录的大小显示为 `-`。 + - `--json`:stdout 输出成功信封 JSON。 +- **`--json` 的 `data`**: + - `files`(数组):文件条目,每项包含 `name`、`size`、`isdir`、`modifytime`。 +- **状态**:[x] Completed + +下载、上传、创建目录、移动和删除不属于本命令范围,由各自独立的文件命令契约定义。 diff --git a/cli/internal/api/file.go b/cli/internal/api/file.go new file mode 100644 index 000000000..f82363403 --- /dev/null +++ b/cli/internal/api/file.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/url" + "strings" + "time" +) + +// FileClient exposes the ordinary-user remote file APIs used by the CLI. +type FileClient interface { + ListFiles(remotePath string) ([]FileInfo, error) +} + +// NewFileClient creates a typed remote-file client. +func NewFileClient(baseURL, token string) FileClient { + return NewClient(baseURL).SetToken(token) +} + +// FileInfo is the stable subset of the storage service file-list response. +type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + IsDir bool `json:"isdir"` + ModifyTime time.Time `json:"modifytime"` +} + +// ListFiles lists a logical user-visible storage path. remotePath must already +// be validated and normalized by the command layer. +func (c *Client) ListFiles(remotePath string) ([]FileInfo, error) { + requestPath := FileListPath + if remotePath != "" { + requestPath += "/" + escapeRemotePath(remotePath) + } + + var result Response[[]FileInfo] + resp, err := c.httpClient.R(). + SetSuccessResult(&result). + SetErrorResult(&result). + Get(requestPath) + if err != nil { + return nil, &NetworkError{Cause: err} + } + if err := errorFromResponse(resp, result.Code, result.Message); err != nil { + return nil, err + } + if result.Data == nil { + return []FileInfo{}, nil + } + return result.Data, nil +} + +func escapeRemotePath(remotePath string) string { + segments := strings.Split(remotePath, "/") + for index := range segments { + segments[index] = url.PathEscape(segments[index]) + } + return strings.Join(segments, "/") +} diff --git a/cli/internal/api/file_test.go b/cli/internal/api/file_test.go new file mode 100644 index 000000000..b8c11666d --- /dev/null +++ b/cli/internal/api/file_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/imroc/req/v3" +) + +func fileTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + client := NewClient("https://example.invalid") + client.httpClient.GetTransport().WrapRoundTripFunc(func(_ http.RoundTripper) req.HttpRoundTripFunc { + return func(request *http.Request) (*http.Response, error) { + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder.Result(), nil + } + }) + return client +} + +func writeFileTestResponse(t *testing.T, writer http.ResponseWriter, data interface{}) { + t.Helper() + writer.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(writer).Encode(map[string]interface{}{ + "code": 0, + "data": data, + "msg": "", + }); err != nil { + t.Fatalf("encode response: %v", err) + } +} + +func TestListFilesRoutesAndDecodes(t *testing.T) { + tests := []struct { + name string + remotePath string + escapedPath string + }{ + {name: "visible root", remotePath: "", escapedPath: "/api/ss/files"}, + {name: "nested ASCII", remotePath: "user/projects", escapedPath: "/api/ss/files/user/projects"}, + {name: "spaces unicode and reserved bytes", remotePath: "user/实验 #1/100%", escapedPath: "/api/ss/files/user/%E5%AE%9E%E9%AA%8C%20%231/100%25"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := fileTestClient(t, func(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + t.Errorf("method = %s, want GET", request.Method) + } + if request.URL.EscapedPath() != test.escapedPath { + t.Errorf("escaped path = %q, want %q", request.URL.EscapedPath(), test.escapedPath) + } + writeFileTestResponse(t, writer, []map[string]interface{}{{ + "name": "checkpoint.bin", + "size": 42, + "isdir": false, + "modifytime": "2026-07-26T08:09:10Z", + "sys": map[string]string{"ignored": "value"}, + }}) + }) + + files, err := client.ListFiles(test.remotePath) + if err != nil { + t.Fatalf("ListFiles: %v", err) + } + if len(files) != 1 || files[0].Name != "checkpoint.bin" || files[0].Size != 42 || files[0].IsDir { + t.Fatalf("files = %#v", files) + } + if got := files[0].ModifyTime.UTC().Format("2006-01-02T15:04:05Z"); got != "2026-07-26T08:09:10Z" { + t.Fatalf("modify time = %q", got) + } + }) + } +} + +func TestListFilesNormalizesNullToEmptySlice(t *testing.T) { + client := fileTestClient(t, func(writer http.ResponseWriter, _ *http.Request) { + writeFileTestResponse(t, writer, nil) + }) + + files, err := client.ListFiles("user/empty") + if err != nil { + t.Fatalf("ListFiles: %v", err) + } + if files == nil || !reflect.DeepEqual(files, []FileInfo{}) { + t.Fatalf("files = %#v, want non-nil empty slice", files) + } +} + +func TestListFilesPreservesStorageError(t *testing.T) { + client := fileTestClient(t, func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(writer).Encode(map[string]interface{}{ + "code": 7, + "data": nil, + "msg": "permission denied", + }) + }) + + _, err := client.ListFiles("public/private") + requestErr, ok := err.(*RequestError) + if !ok { + t.Fatalf("error = %T %v, want *RequestError", err, err) + } + if requestErr.HTTPStatus != http.StatusUnauthorized || requestErr.CraterCode != 7 || requestErr.Msg != "permission denied" { + t.Fatalf("request error = %#v", requestErr) + } +} diff --git a/cli/internal/api/paths.go b/cli/internal/api/paths.go index 6ccc6fce4..7ea12ae55 100644 --- a/cli/internal/api/paths.go +++ b/cli/internal/api/paths.go @@ -27,6 +27,7 @@ const ( AdminQueueQuotasPfx = "/api/v1/admin/queue-quotas" AdminGPUAnalysisPfx = "/api/v1/admin/gpu-analysis" SystemConfigPrefix = "/api/v1/system-config" + StoragePrefix = "/api/ss" AdminSysConfigPfx = "/api/v1/admin/system-config" UsersPrefix = "/api/v1/users" AdminUsersPrefix = "/api/v1/admin/users" @@ -54,4 +55,5 @@ const ( VCJobListPath = VCJobsPrefix VCJobBillingPath = VCJobsPrefix + "/billing" AdminVCJobBillingPath = AdminVCJobsPrefix + "/billing" + FileListPath = StoragePrefix + "/files" ) diff --git a/cli/internal/i18n/catalog_file.go b/cli/internal/i18n/catalog_file.go new file mode 100644 index 000000000..21cfce17e --- /dev/null +++ b/cli/internal/i18n/catalog_file.go @@ -0,0 +1,32 @@ +package i18n + +var catalogFile = map[Language]map[string]string{ + En: { + "file_short": "View remote files", + "file_long": "List files in user, public, and account storage spaces.", + "file_ls_short": "List remote files", + "err_file_path_invalid": "invalid remote path %q", + "err_file_path_root": "remote path must start with user, public, or account: %q", + "file_type_directory": "directory", + "file_type_regular": "file", + "file_table_size": "SIZE", + "file_table_modified": "MODIFIED", + "file_root_user_desc": "Your private user storage.", + "file_root_public_desc": "Shared public storage.", + "file_root_account_desc": "Storage for the current account.", + }, + ZhCN: { + "file_short": "查看远端文件", + "file_long": "列出用户、公共及当前账户存储空间中的文件。", + "file_ls_short": "列出远端文件", + "err_file_path_invalid": "无效的远端路径 %q", + "err_file_path_root": "远端路径必须以 user、public 或 account 开头:%q", + "file_type_directory": "目录", + "file_type_regular": "文件", + "file_table_size": "大小", + "file_table_modified": "修改时间", + "file_root_user_desc": "当前用户的私有存储空间", + "file_root_public_desc": "共享公共存储空间", + "file_root_account_desc": "当前账户的存储空间", + }, +} diff --git a/cli/internal/i18n/i18n.go b/cli/internal/i18n/i18n.go index c2a68e3a2..003729a13 100644 --- a/cli/internal/i18n/i18n.go +++ b/cli/internal/i18n/i18n.go @@ -24,6 +24,7 @@ var translations = mergeCatalogs( catalogCompletion, catalogCompatibility, catalogDownload, + catalogFile, catalogRead, catalogImage, catalogOrder, diff --git a/cli/skills/crater-cli-file/SKILL.md b/cli/skills/crater-cli-file/SKILL.md new file mode 100644 index 000000000..faeaa7ab7 --- /dev/null +++ b/cli/skills/crater-cli-file/SKILL.md @@ -0,0 +1,45 @@ +--- +name: crater-cli-file +version: 0.1.0 +description: "Use Crater CLI to list ordinary-user remote files in user, public, and account storage spaces." +metadata: + requires: + bins: ["crater"] + cliHelp: "crater file --help" +--- + +# Crater CLI File + +**CRITICAL — Before doing anything else, MUST read `crater-cli-shared` (possible path: [`../crater-cli-shared/SKILL.md`](../crater-cli-shared/SKILL.md)) for global options, non-interactive use, errors, and sensitive information handling.** + +Use `crater file` when a user needs to inspect files visible through their ordinary Crater identity. + +## Supported workflow + +- List visible storage roots: `crater file ls` +- List a nested directory: `crater file ls ` +- Return structured data for a script or agent: add `--json --no-interactive` + +Remote paths are logical Crater paths. They must start with `user`, `public`, or `account`; do not pass local filesystem paths or construct paths containing `.` or `..`. + +## Safety + +- `file ls` is read-only. +- Do not ask the user to provide a token or Keyring content. +- Do not substitute `crater admin ...` endpoints for an ordinary-user request. +- Prefer exact paths shown by a previous `file ls` result. + +## Examples + +```bash +crater file ls --json --no-interactive +crater file ls user/projects --json --no-interactive +crater file ls "account/共享数据" --json --no-interactive +``` + +## Troubleshooting + +1. Run `crater auth ls --json` and confirm an active context exists. +2. Use `crater file ls --help` to verify the local binary supports the command. +3. A path validation error means the path is outside the ordinary-user logical roots or contains an unsafe segment. +4. For API errors, inspect `category`, `code`, and `context.http_status` from JSON stderr without exposing credentials. diff --git a/cli/test/snapshots/file/file_test.go b/cli/test/snapshots/file/file_test.go new file mode 100644 index 000000000..837210d1a --- /dev/null +++ b/cli/test/snapshots/file/file_test.go @@ -0,0 +1,55 @@ +package file_test + +import ( + "os" + "testing" + + "github.com/raids-lab/crater/cli/internal/snaptest" +) + +const goldenStemFile = "file" + +func TestFileSnapshotsEN(t *testing.T) { + runFileSnapshots(t, "en") +} + +func TestFileSnapshotsZhCN(t *testing.T) { + runFileSnapshots(t, "zh-CN") +} + +func runFileSnapshots(t *testing.T, language string) { + t.Helper() + path := snaptest.GoldenFileT(t, "file", goldenStemFile, language) + home := t.TempDir() + baseEnv := snaptest.EnvMinimal(home, language) + binary := snaptest.CraterExecutable(t) + cases := []snaptest.Case{ + {ID: "01-file-typo-json", Args: []string{"file", "list", "--json", "--no-interactive"}}, + {ID: "02-file-ls-extra-arg-json", Args: []string{"file", "ls", "user", "extra", "--json", "--no-interactive"}}, + {ID: "03-file-ls-traversal-json", Args: []string{"file", "ls", "user/../public", "--json", "--no-interactive"}}, + {ID: "04-file-ls-invalid-root-json", Args: []string{"file", "ls", "admin/secret", "--json", "--no-interactive"}}, + {ID: "05-file-ls-root-404-json", Args: []string{"file", "ls", "--json", "--no-interactive"}}, + {ID: "06-file-ls-unicode-404-json", Args: []string{"file", "ls", "user/实验 data", "--json", "--no-interactive"}}, + {ID: "07-file-help", Args: []string{"file", "--help"}}, + {ID: "08-file-ls-help", Args: []string{"file", "ls", "--help"}}, + } + + results := make([]*snaptest.Result, len(cases)) + for index := range cases { + environment := baseEnv + switch cases[index].ID { + case "05-file-ls-root-404-json", "06-file-ls-unicode-404-json": + environment = append(baseEnv, "CRATER_TEST_SANDBOX_HTTP=error404") + } + result, err := snaptest.Run(binary, environment, cases[index].Args) + if err != nil { + t.Fatalf("case %s: %v", cases[index].ID, err) + } + results[index] = result + } + + update := os.Getenv("UPDATE_SNAPSHOTS") == "1" || os.Getenv("UPDATE_SNAPSHOTS") == "true" + if err := snaptest.MatchOrUpdateGolden(path, language, cases, results, update); err != nil { + t.Fatal(err) + } +} diff --git a/cli/testdata/snapshots/file/file.en.txtar b/cli/testdata/snapshots/file/file.en.txtar new file mode 100644 index 000000000..9e0f81199 --- /dev/null +++ b/cli/testdata/snapshots/file/file.en.txtar @@ -0,0 +1,113 @@ +# Crater CLI snapshot bundle (txtar). Regenerate: make snapshot-update (or UPDATE_SNAPSHOTS=1 go test ./test/snapshots/...) +-- en/01-file-typo-json/argv -- +crater file list --json --no-interactive +-- en/01-file-typo-json/exit -- +2 +-- en/01-file-typo-json/stdout -- +-- en/01-file-typo-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_UNKNOWN_COMMAND", + "message": "unknown command \"list\" for \"crater file\"\n\nDid you mean this?\n\tls\n\nRun \"crater file --help\" for usage." +} +-- en/02-file-ls-extra-arg-json/argv -- +crater file ls user extra --json --no-interactive +-- en/02-file-ls-extra-arg-json/exit -- +2 +-- en/02-file-ls-extra-arg-json/stdout -- +-- en/02-file-ls-extra-arg-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "too many arguments for crater file ls: got 2, want at most 1" +} +-- en/03-file-ls-traversal-json/argv -- +crater file ls user/../public --json --no-interactive +-- en/03-file-ls-traversal-json/exit -- +2 +-- en/03-file-ls-traversal-json/stdout -- +-- en/03-file-ls-traversal-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "invalid remote path \"user/../public\"" +} +-- en/04-file-ls-invalid-root-json/argv -- +crater file ls admin/secret --json --no-interactive +-- en/04-file-ls-invalid-root-json/exit -- +2 +-- en/04-file-ls-invalid-root-json/stdout -- +-- en/04-file-ls-invalid-root-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "remote path must start with user, public, or account: \"admin/secret\"" +} +-- en/05-file-ls-root-404-json/argv -- +crater file ls --json --no-interactive +-- en/05-file-ls-root-404-json/exit -- +4 +-- en/05-file-ls-root-404-json/stdout -- +-- en/05-file-ls-root-404-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NOT_FOUND_404", + "message": "HTTP 404: simulated", + "context": { + "crater_code": 404, + "http_status": 404, + "msg": "simulated" + } +} +-- en/06-file-ls-unicode-404-json/argv -- +crater file ls user/实验 data --json --no-interactive +-- en/06-file-ls-unicode-404-json/exit -- +4 +-- en/06-file-ls-unicode-404-json/stdout -- +-- en/06-file-ls-unicode-404-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NOT_FOUND_404", + "message": "HTTP 404: simulated", + "context": { + "crater_code": 404, + "http_status": 404, + "msg": "simulated" + } +} +-- en/07-file-help/argv -- +crater file --help +-- en/07-file-help/exit -- +0 +-- en/07-file-help/stdout -- +List files in user, public, and account storage spaces. + +Usage: + crater file [flags] + crater file [command] + +Available Commands: + ls List remote files + +Global Flags: + -h, --help Help for crater + --json Output in raw JSON format + --no-interactive Disable interactive prompts + +Use "crater file [command] --help" for more information about a command. +-- en/07-file-help/stderr -- +-- en/08-file-ls-help/argv -- +crater file ls --help +-- en/08-file-ls-help/exit -- +0 +-- en/08-file-ls-help/stdout -- +List remote files + +Usage: + crater file ls [remote-path] [flags] + +Global Flags: + -h, --help Help for crater + --json Output in raw JSON format + --no-interactive Disable interactive prompts +-- en/08-file-ls-help/stderr -- diff --git a/cli/testdata/snapshots/file/file.zh-CN.txtar b/cli/testdata/snapshots/file/file.zh-CN.txtar new file mode 100644 index 000000000..db425bd2a --- /dev/null +++ b/cli/testdata/snapshots/file/file.zh-CN.txtar @@ -0,0 +1,113 @@ +# Crater CLI snapshot bundle (txtar). Regenerate: make snapshot-update (or UPDATE_SNAPSHOTS=1 go test ./test/snapshots/...) +-- zh-CN/01-file-typo-json/argv -- +crater file list --json --no-interactive +-- zh-CN/01-file-typo-json/exit -- +2 +-- zh-CN/01-file-typo-json/stdout -- +-- zh-CN/01-file-typo-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_UNKNOWN_COMMAND", + "message": "unknown command \"list\" for \"crater file\"\n\nDid you mean this?\n\tls\n\nRun \"crater file --help\" for usage." +} +-- zh-CN/02-file-ls-extra-arg-json/argv -- +crater file ls user extra --json --no-interactive +-- zh-CN/02-file-ls-extra-arg-json/exit -- +2 +-- zh-CN/02-file-ls-extra-arg-json/stdout -- +-- zh-CN/02-file-ls-extra-arg-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "crater file ls 的参数过多:收到 2 个,最多允许 1 个" +} +-- zh-CN/03-file-ls-traversal-json/argv -- +crater file ls user/../public --json --no-interactive +-- zh-CN/03-file-ls-traversal-json/exit -- +2 +-- zh-CN/03-file-ls-traversal-json/stdout -- +-- zh-CN/03-file-ls-traversal-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "无效的远端路径 \"user/../public\"" +} +-- zh-CN/04-file-ls-invalid-root-json/argv -- +crater file ls admin/secret --json --no-interactive +-- zh-CN/04-file-ls-invalid-root-json/exit -- +2 +-- zh-CN/04-file-ls-invalid-root-json/stdout -- +-- zh-CN/04-file-ls-invalid-root-json/stderr -- +{ + "category": "usage_error", + "code": "ERR_INVALID_FLAG_VALUE", + "message": "远端路径必须以 user、public 或 account 开头:\"admin/secret\"" +} +-- zh-CN/05-file-ls-root-404-json/argv -- +crater file ls --json --no-interactive +-- zh-CN/05-file-ls-root-404-json/exit -- +4 +-- zh-CN/05-file-ls-root-404-json/stdout -- +-- zh-CN/05-file-ls-root-404-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NOT_FOUND_404", + "message": "请求失败(HTTP 404):simulated", + "context": { + "crater_code": 404, + "http_status": 404, + "msg": "simulated" + } +} +-- zh-CN/06-file-ls-unicode-404-json/argv -- +crater file ls user/实验 data --json --no-interactive +-- zh-CN/06-file-ls-unicode-404-json/exit -- +4 +-- zh-CN/06-file-ls-unicode-404-json/stdout -- +-- zh-CN/06-file-ls-unicode-404-json/stderr -- +{ + "category": "api_error", + "code": "ERR_NOT_FOUND_404", + "message": "请求失败(HTTP 404):simulated", + "context": { + "crater_code": 404, + "http_status": 404, + "msg": "simulated" + } +} +-- zh-CN/07-file-help/argv -- +crater file --help +-- zh-CN/07-file-help/exit -- +0 +-- zh-CN/07-file-help/stdout -- +列出用户、公共及当前账户存储空间中的文件。 + +Usage: + crater file [flags] + crater file [command] + +Available Commands: + ls 列出远端文件 + +Global Flags: + -h, --help 显示帮助信息 + --json 以原始 JSON 格式输出 + --no-interactive 禁用交互式提示 + +Use "crater file [command] --help" for more information about a command. +-- zh-CN/07-file-help/stderr -- +-- zh-CN/08-file-ls-help/argv -- +crater file ls --help +-- zh-CN/08-file-ls-help/exit -- +0 +-- zh-CN/08-file-ls-help/stdout -- +列出远端文件 + +Usage: + crater file ls [remote-path] [flags] + +Global Flags: + -h, --help 显示帮助信息 + --json 以原始 JSON 格式输出 + --no-interactive 禁用交互式提示 +-- zh-CN/08-file-ls-help/stderr -- diff --git a/output/playwright/issue-479-file-list.png b/output/playwright/issue-479-file-list.png new file mode 100644 index 000000000..4e018e553 Binary files /dev/null and b/output/playwright/issue-479-file-list.png differ