From 336eeaf18ec2acb3bb65e326d040714efe4d90ab Mon Sep 17 00:00:00 2001 From: fangshuyu Date: Thu, 14 May 2026 10:39:19 +0800 Subject: [PATCH] feat(drive): add quick mode to status diff Add a best-effort --quick path to drive +status so repeated checks can compare local mtimes with Drive modified_time without downloading remote bytes. Keep the default exact hash mode intact, expose detection=exact|quick in output, and cover the quick semantics with unit, dry-run, and live E2E tests. --- shortcuts/drive/drive_status.go | 81 ++++-- shortcuts/drive/drive_status_test.go | 150 ++++++++++ skills/lark-drive/SKILL.md | 2 +- .../references/lark-drive-status.md | 26 +- .../cli_e2e/drive/drive_status_dryrun_test.go | 44 +++ .../drive/drive_status_workflow_test.go | 264 ++++++++++++++++++ 6 files changed, 544 insertions(+), 23 deletions(-) diff --git a/shortcuts/drive/drive_status.go b/shortcuts/drive/drive_status.go index de811a3f5..1ec9a1815 100644 --- a/shortcuts/drive/drive_status.go +++ b/shortcuts/drive/drive_status.go @@ -13,6 +13,7 @@ import ( "path/filepath" "sort" "strings" + "time" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" @@ -26,8 +27,24 @@ type driveStatusEntry struct { FileToken string `json:"file_token,omitempty"` } +type driveStatusLocalFile struct { + PathToCwd string + ModTime time.Time +} + +type driveStatusRemoteFile struct { + FileToken string + ModifiedTime string +} + +const ( + driveStatusDetectionExact = "exact" + driveStatusDetectionQuick = "quick" +) + // DriveStatus walks --local-dir, recursively lists --folder-token, and reports -// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash. +// four buckets (new_local, new_remote, modified, unchanged) either by exact +// SHA-256 hash (default) or by a quick modified_time comparison (--quick). // // Only Drive entries with type=file are compared; online docs (docx, sheet, // bitable, mindnote, slides) and shortcuts are skipped because there is no @@ -39,17 +56,19 @@ type driveStatusEntry struct { var DriveStatus = common.Shortcut{ Service: "drive", Command: "+status", - Description: "Compare a local directory with a Drive folder by content hash", + Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time", Risk: "read", Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"}, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true}, {Name: "folder-token", Desc: "Drive folder token", Required: true}, + {Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"}, }, Tips: []string{ "Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", - "Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.", + "Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.", + "Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) @@ -80,14 +99,22 @@ var DriveStatus = common.Shortcut{ return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256." + if runtime.Bool("quick") { + desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes." + } return common.NewDryRunAPI(). - Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."). + Desc(desc). GET("/open-apis/drive/v1/files"). Set("folder_token", runtime.Str("folder-token")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { localDir := strings.TrimSpace(runtime.Str("local-dir")) folderToken := strings.TrimSpace(runtime.Str("folder-token")) + detection := driveStatusDetectionExact + if runtime.Bool("quick") { + detection = driveStatusDetectionQuick + } // Resolve --local-dir to its canonical absolute path before walking. // SafeInputPath fully evaluates symlinks across the entire path, @@ -112,7 +139,7 @@ var DriveStatus = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir) - localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical) + localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical) if err != nil { return err } @@ -130,30 +157,42 @@ var DriveStatus = common.Shortcut{ // hashable bytes and are intentionally absent from the diff // view (a docx living next to a same-named local file is a // known no-op). - remoteFiles := make(map[string]string, len(entries)) + remoteFiles := make(map[string]driveStatusRemoteFile, len(entries)) for _, entry := range entries { if entry.Type == driveTypeFile { - remoteFiles[entry.RelPath] = entry.FileToken + remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime} } } - paths := mergeStatusPaths(localHashes, remoteFiles) + paths := mergeStatusPaths(localFiles, remoteFiles) var newLocal, newRemote, modified, unchanged []driveStatusEntry for _, relPath := range paths { - localHash, hasLocal := localHashes[relPath] - remoteToken, hasRemote := remoteFiles[relPath] + localFile, hasLocal := localFiles[relPath] + remoteFile, hasRemote := remoteFiles[relPath] switch { case hasLocal && !hasRemote: newLocal = append(newLocal, driveStatusEntry{RelPath: relPath}) case !hasLocal && hasRemote: - newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken}) + newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}) default: - remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken) + entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken} + if detection == driveStatusDetectionQuick { + if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) { + unchanged = append(unchanged, entry) + } else { + modified = append(modified, entry) + } + continue + } + localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd) + if err != nil { + return err + } + remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken) if err != nil { return err } - entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken} if localHash == remoteHash { unchanged = append(unchanged, entry) } else { @@ -163,6 +202,7 @@ var DriveStatus = common.Shortcut{ } runtime.Out(map[string]interface{}{ + "detection": detection, "new_local": emptyIfNil(newLocal), "new_remote": emptyIfNil(newRemote), "modified": emptyIfNil(modified), @@ -180,8 +220,8 @@ var DriveStatus = common.Shortcut{ // hit, we report rel_path relative to root for the JSON output, and // convert the absolute path to a cwd-relative form so FileIO.Open's // SafeInputPath check (which rejects absolute paths) still applies. -func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) { - files := make(map[string]string) +func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) { + files := make(map[string]driveStatusLocalFile) // FileIO has no walker today and shortcuts can't import internal/vfs. // The walk root is the canonical absolute path returned by // validate.SafeInputPath, so it is no longer a symlink itself, and @@ -202,11 +242,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin if err != nil { return err } - sum, err := hashLocalForStatus(runtime, relToCwd) + info, err := d.Info() if err != nil { return err } - files[filepath.ToSlash(rel)] = sum + files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()} return nil }) if err != nil { @@ -215,6 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin return files, nil } +func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool { + cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local) + return ok && cmp == 0 +} + func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) { f, err := runtime.FileIO().Open(path) if err != nil { @@ -244,7 +289,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi return hex.EncodeToString(h.Sum(nil)), nil } -func mergeStatusPaths(local, remote map[string]string) []string { +func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string { seen := make(map[string]struct{}, len(local)+len(remote)) for p := range local { seen[p] = struct{}{} diff --git a/shortcuts/drive/drive_status_test.go b/shortcuts/drive/drive_status_test.go index 9f20e6fbf..b95f04f25 100644 --- a/shortcuts/drive/drive_status_test.go +++ b/shortcuts/drive/drive_status_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" @@ -105,6 +106,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) { } out := stdout.String() + if !strings.Contains(out, `"detection": "exact"`) { + t.Fatalf("output missing detection=exact\noutput: %s", out) + } checks := []struct { bucket string path string @@ -134,6 +138,152 @@ func TestDriveStatusCategorizesByHash(t *testing.T) { reg.Verify(t) } +func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local/sub", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil { + t.Fatalf("WriteFile b.txt: %v", err) + } + if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil { + t.Fatalf("WriteFile sub/c.txt: %v", err) + } + + matchTime := time.Unix(1715594880, 0) + changedTime := time.Unix(1715594940, 0) + if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil { + t.Fatalf("Chtimes a.txt: %v", err) + } + if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil { + t.Fatalf("Chtimes sub/c.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"}, + map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"}, + map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=tok_sub", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveStatus, []string{ + "+status", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Fatalf("output missing detection=quick\noutput: %s", out) + } + checks := []struct { + bucket string + path string + token string + }{ + {"new_local", "b.txt", ""}, + {"new_remote", "d.txt", "tok_d"}, + {"modified", "sub/c.txt", "tok_c"}, + {"unchanged", "a.txt", "tok_a"}, + } + for _, c := range checks { + if !strings.Contains(out, `"`+c.bucket+`":`) { + t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out) + } + if !strings.Contains(out, `"rel_path": "`+c.path+`"`) { + t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out) + } + if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) { + t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out) + } + } + + reg.Verify(t) +} + +func TestDriveStatusQuickMarksUntrustedTimestampAsModified(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile("local/a.txt", []byte("local"), 0o644); err != nil { + t.Fatalf("WriteFile a.txt: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"}, + }, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveStatus, []string{ + "+status", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String()) + } + + out := stdout.String() + if !strings.Contains(out, `"detection": "quick"`) { + t.Fatalf("output missing detection=quick\noutput: %s", out) + } + if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) { + t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out) + } + + reg.Verify(t) +} + // TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end // AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces // `next_page_token` (Drive's historical name); page 2 surfaces `page_token` diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 1a171d5f1..1b6a2cc1b 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -229,7 +229,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node | | [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support | | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | -| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | +| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact|quick`. Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 | | [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides | diff --git a/skills/lark-drive/references/lark-drive-status.md b/skills/lark-drive/references/lark-drive-status.md index f9ad2ae05..0afbea85f 100644 --- a/skills/lark-drive/references/lark-drive-status.md +++ b/skills/lark-drive/references/lark-drive-status.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异: +按 **精确 SHA-256**(默认)或 **快速 modified_time**(`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异: | 字段 | 含义 | |------|------| @@ -12,7 +12,10 @@ | `modified` | 双端都存在但 hash 不一致 | | `unchanged` | 双端都存在且 hash 一致 | -只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。 +只读命令: + +- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash,不下载落盘,但大目录 / 大文件会有可观的网络流量。 +- 传 `--quick` 后 `detection=quick`:只比较本地 mtime 与远端 `modified_time`,**不下载远端文件内容**,适合先做快速预检查;它是 best-effort,不等同于严格内容一致性判断。 ## 远端同名文件冲突 @@ -26,6 +29,12 @@ lark-cli drive +status \ --local-dir ./repo \ --folder-token fldcnxxxxxxxxx +# 快速模式 —— 只比较 modified_time,不下载远端文件内容 +lark-cli drive +status \ + --local-dir ./repo \ + --folder-token fldcnxxxxxxxxx \ + --quick + # 只看 hash 不一致的项(结合 --jq 过滤) lark-cli drive +status \ --local-dir ./repo \ @@ -39,6 +48,7 @@ lark-cli drive +status \ |------|------|------|------| | `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) | | `--folder-token` | 是 | string | Drive 文件夹 token | +| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` | ## 输出 schema @@ -46,6 +56,7 @@ lark-cli drive +status \ ```json { + "detection": "exact", "new_local": [{"rel_path": "..."}], "new_remote": [{"rel_path": "...", "file_token": "..."}], "modified": [{"rel_path": "...", "file_token": "..."}], @@ -53,6 +64,11 @@ lark-cli drive +status \ } ``` +其中: + +- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。 +- `detection=quick`:`--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。 + `rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。 远端同名文件冲突时: @@ -84,6 +100,7 @@ lark-cli drive +status \ - 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。 - 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。 - 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。 +- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime:相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`。 ## 范围限制 @@ -99,9 +116,10 @@ lark-cli drive +status \ ## 性能注意 -- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。 +- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。 +- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`。 - 仅一侧存在的文件不会被下载。 -- Hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。 +- 默认模式的 hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。 ## 所需 scope diff --git a/tests/cli_e2e/drive/drive_status_dryrun_test.go b/tests/cli_e2e/drive/drive_status_dryrun_test.go index 12f2ee3d8..434dcfa71 100644 --- a/tests/cli_e2e/drive/drive_status_dryrun_test.go +++ b/tests/cli_e2e/drive/drive_status_dryrun_test.go @@ -70,6 +70,50 @@ func TestDrive_StatusDryRun(t *testing.T) { } } +func TestDrive_StatusDryRunQuick(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", "fldcnE2E001", + "--quick", + "--dry-run", + }, + WorkDir: workDir, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" { + t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" { + t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out) + } + desc := gjson.Get(out, "description").String() + if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") { + t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out) + } +} + // TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the // --local-dir path validator runs in the real binary's Validate stage and // surfaces a structured error referencing --local-dir (not the framework diff --git a/tests/cli_e2e/drive/drive_status_workflow_test.go b/tests/cli_e2e/drive/drive_status_workflow_test.go index ba39d8e33..39a53deb9 100644 --- a/tests/cli_e2e/drive/drive_status_workflow_test.go +++ b/tests/cli_e2e/drive/drive_status_workflow_test.go @@ -5,8 +5,10 @@ package drive import ( "context" + "fmt" "os" "path/filepath" + "strconv" "testing" "time" @@ -183,4 +185,266 @@ func TestDrive_StatusWorkflow(t *testing.T) { t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out) } } + + quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + "--quick", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + quickResult.AssertExitCode(t, 0) + quickResult.AssertStdoutStatus(t, true) + + quickOut := quickResult.Stdout + if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" { + t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut) + } + if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 { + t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut) + } + if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 { + t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut) + } + if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" { + t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut) + } + if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" { + t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut) + } + sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int()) + if sharedCount != 2 { + t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut) + } + for _, path := range []string{"unchanged.txt", "modified.txt"} { + if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() { + t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut) + } + } +} + +// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time +// semantics on the live backend instead of silently behaving like the default +// exact hash mode. +// +// The fixture intentionally makes the two shared files diverge in opposite ways: +// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified +// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged +// +// This locks in the best-effort nature of quick mode with real Drive +// modified_time values fetched from the list API, plus the expected new_local / +// new_remote buckets. +func TestDrive_StatusQuickWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + folderName := "lark-cli-e2e-drive-status-quick-" + suffix + folderToken := createDriveFolder(t, parentT, ctx, folderName, "") + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil { + t.Fatalf("mkdir local: %v", err) + } + + writeLocal := func(rel, content string) { + t.Helper() + full := filepath.Join(workDir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir parent of %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + + uploadDriveFile := func(name, content string) string { + t.Helper() + stage := "_upload_" + name + writeLocal(stage, content) + t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) }) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+upload", + "--file", stage, + "--folder-token", folderToken, + "--name", name, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(result.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{ + Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"}, + DefaultAs: "bot", + }, clie2e.RetryOptions{}) + clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr) + }) + return fileToken + } + + tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A") + tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B") + tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only") + + remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken) + sameMtimeRemote := remoteFiles["same-mtime.txt"] + remoteNewer := remoteFiles["remote-newer.txt"] + if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" { + t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles) + } + + writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote + writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote + writeLocal("local/local-only.txt", "local only") // local-only bucket + + sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt") + remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt") + sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime) + remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime) + if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil { + t.Fatalf("chtimes same-mtime.txt: %v", err) + } + localOlder := remoteNewerAt.Add(-2 * time.Second) + if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil { + t.Fatalf("chtimes remote-newer.txt: %v", err) + } + + quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + "--quick", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + quickResult.AssertExitCode(t, 0) + quickResult.AssertStdoutStatus(t, true) + + quickOut := quickResult.Stdout + if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" { + t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut) + } + assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime) + assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer) + assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "") + assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly) + assertStatusBucketLen(t, quickOut, "unchanged", 1) + assertStatusBucketLen(t, quickOut, "modified", 1) + assertStatusBucketLen(t, quickOut, "new_local", 1) + assertStatusBucketLen(t, quickOut, "new_remote", 1) + + exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + exactResult.AssertExitCode(t, 0) + exactResult.AssertStdoutStatus(t, true) + + exactOut := exactResult.Stdout + if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" { + t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut) + } + assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime) + assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer) + assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "") + assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly) + assertStatusBucketLen(t, exactOut, "unchanged", 1) + assertStatusBucketLen(t, exactOut, "modified", 1) + assertStatusBucketLen(t, exactOut, "new_local", 1) + assertStatusBucketLen(t, exactOut, "new_remote", 1) +} + +type driveStatusListedFile struct { + Token string + ModifiedTime string +} + +func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile { + t.Helper() + params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken) + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"drive", "files", "list", "--params", params}, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + files := make(map[string]driveStatusListedFile) + gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool { + name := entry.Get("name").String() + if name == "" { + return true + } + files[name] = driveStatusListedFile{ + Token: entry.Get("token").String(), + ModifiedTime: entry.Get("modified_time").String(), + } + return true + }) + return files +} + +func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time { + t.Helper() + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + t.Fatalf("parse Drive epoch %q: %v", raw, err) + } + switch { + case v > 1e14 || v < -1e14: + return time.UnixMicro(v) + case v > 1e11 || v < -1e11: + return time.UnixMilli(v) + default: + return time.Unix(v, 0) + } +} + +func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) { + t.Helper() + entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`) + if !entry.Exists() { + t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout) + } + if fileToken == "" { + if got := entry.Get("file_token").String(); got != "" { + t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout) + } + return + } + if got := entry.Get("file_token").String(); got != fileToken { + t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout) + } +} + +func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) { + t.Helper() + if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want { + t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout) + } }