mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 16:18:05 +08:00
* fix: reject +init into a different app's project directory * fix: reject single HTML files larger than 10MB in +html-publish * docs: clarify publish visibility, domain routing, and role/permission boundary
1648 lines
59 KiB
Go
1648 lines
59 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package apps
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/spf13/cobra"
|
||
|
||
"github.com/larksuite/cli/errs"
|
||
"github.com/larksuite/cli/internal/cmdutil"
|
||
"github.com/larksuite/cli/internal/core"
|
||
"github.com/larksuite/cli/shortcuts/common"
|
||
)
|
||
|
||
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
|
||
// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered,
|
||
// mirroring how +init reads them at runtime via rctx.Str.
|
||
func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext {
|
||
t.Helper()
|
||
cmd := &cobra.Command{Use: "init"}
|
||
cmd.Flags().String("dir", dirFlag, "")
|
||
cmd.Flags().String("template", defaultTemplate, "")
|
||
return common.TestNewRuntimeContext(cmd, nil)
|
||
}
|
||
|
||
// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and
|
||
// "template" string flags registered, mirroring +init's runtime flag set. The
|
||
// template flag is registered with an empty default (matching the real flag,
|
||
// which no longer carries Default: defaultTemplate); pass tpl="" to model an
|
||
// omitted --template and a non-empty tpl to model an explicit one.
|
||
func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext {
|
||
t.Helper()
|
||
cmd := &cobra.Command{Use: "init"}
|
||
cmd.Flags().String("dir", dirFlag, "")
|
||
cmd.Flags().String("template", tpl, "")
|
||
return common.TestNewRuntimeContext(cmd, nil)
|
||
}
|
||
|
||
func TestResolveTemplate(t *testing.T) {
|
||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" {
|
||
t.Errorf("explicit --template = %q, want foo", got)
|
||
}
|
||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate {
|
||
t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate)
|
||
}
|
||
// Whitespace-only --template is treated as omitted -> fallback.
|
||
if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate {
|
||
t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate)
|
||
}
|
||
}
|
||
|
||
func TestResolveTargetPath(t *testing.T) {
|
||
got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x")
|
||
if err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
want, _ := filepath.Abs(filepath.Join(".", "app_x"))
|
||
if got != want {
|
||
t.Errorf("default dir = %q, want %q", got, want)
|
||
}
|
||
abs := t.TempDir() + "/work"
|
||
if got, err := resolveTargetPath(testRuntimeWithDir(t, abs), "app_x"); err != nil || got != filepath.Clean(abs) {
|
||
t.Errorf("absolute --dir = %q, err=%v; want %q", got, err, filepath.Clean(abs))
|
||
}
|
||
for _, bad := range []string{"bad\tdir", "bad\ndir", "bad\x01dir", "a\rb"} {
|
||
if _, err := resolveTargetPath(testRuntimeWithDir(t, bad), "app_x"); err == nil {
|
||
t.Errorf("control char %q in --dir should be rejected", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnsureEmptyDir_SymlinkRejected(t *testing.T) {
|
||
base := t.TempDir()
|
||
target := filepath.Join(base, "real")
|
||
if err := os.Mkdir(target, 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
link := filepath.Join(base, "link")
|
||
if err := os.Symlink(target, link); err != nil {
|
||
t.Skipf("symlink unsupported: %v", err)
|
||
}
|
||
if err := ensureEmptyDir(link); err == nil {
|
||
t.Error("symlink target must be rejected")
|
||
}
|
||
}
|
||
|
||
func TestIsAlreadyInitialized(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if isAlreadyInitialized(dir) {
|
||
t.Error("empty dir must not be already-initialized")
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, ".spark", "meta.json"), []byte(`{"app_id":"app_y"}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !isAlreadyInitialized(dir) {
|
||
t.Error("dir with .spark/meta.json must be already-initialized (regardless of app_id)")
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_Declaration(t *testing.T) {
|
||
if AppsInit.Command != "+init" {
|
||
t.Errorf("Command = %q, want +init", AppsInit.Command)
|
||
}
|
||
if AppsInit.Service != appsService {
|
||
t.Errorf("Service = %q, want %q", AppsInit.Service, appsService)
|
||
}
|
||
if AppsInit.Risk != "write" {
|
||
t.Errorf("Risk = %q, want write", AppsInit.Risk)
|
||
}
|
||
if !AppsInit.HasFormat {
|
||
t.Error("HasFormat = false, want true")
|
||
}
|
||
}
|
||
|
||
func TestDefaultCloneDir(t *testing.T) {
|
||
got := defaultCloneDir("app_xyz")
|
||
if got != filepath.Join(".", "app_xyz") {
|
||
t.Errorf("defaultCloneDir = %q, want ./app_xyz", got)
|
||
}
|
||
}
|
||
|
||
// --- pure-function tests ---
|
||
|
||
func TestParseRepoURL(t *testing.T) {
|
||
url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if url != "http://u:t@h/app_x.git" {
|
||
t.Errorf("got %q", url)
|
||
}
|
||
}
|
||
|
||
func TestParseRepoURL_Errors(t *testing.T) {
|
||
for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} {
|
||
if _, err := parseRepoURLFromEnvelope(in); err == nil {
|
||
t.Errorf("expected error for %q", in)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidateRepoURLScheme(t *testing.T) {
|
||
for _, ok := range []string{"http://h/r.git", "https://h/r.git"} {
|
||
if err := validateRepoURLScheme(ok); err != nil {
|
||
t.Errorf("%q should be valid: %v", ok, err)
|
||
}
|
||
}
|
||
for _, bad := range []string{"ext::sh -c id", "file:///etc/passwd", "ssh://h/r", "-oProxyCommand=x", "git@h:r"} {
|
||
if err := validateRepoURLScheme(bad); err == nil {
|
||
t.Errorf("%q should be rejected", bad)
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- orchestration test helpers ---
|
||
|
||
func withFakeRunner(t *testing.T, f *fakeCommandRunner) {
|
||
t.Helper()
|
||
orig := initRunner
|
||
initRunner = f
|
||
t.Cleanup(func() { initRunner = orig })
|
||
}
|
||
|
||
func credInitOK(repoURL string) fakeCallResult {
|
||
return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`}
|
||
}
|
||
|
||
// relCloneDir returns a relative, cwd-contained, not-yet-existing directory
|
||
// name suitable for --dir. SafeInputPath rejects absolute paths (so
|
||
// t.TempDir() cannot be used directly) and requires the path stay under cwd.
|
||
// The fake runner never creates the dir, so ensureEmptyDir sees a missing path
|
||
// and passes. Cleanup removes it in case anything materializes it.
|
||
func relCloneDir(t *testing.T) string {
|
||
t.Helper()
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
rel := "init-clone-" + strings.ReplaceAll(t.Name(), "/", "_")
|
||
t.Cleanup(func() { os.RemoveAll(filepath.Join(cwd, rel)) })
|
||
return rel
|
||
}
|
||
|
||
// parseEnvelopeData parses the JSON envelope's data object from stdout.
|
||
func parseEnvelopeData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||
t.Helper()
|
||
var env struct {
|
||
Data map[string]interface{} `json:"data"`
|
||
}
|
||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||
t.Fatalf("decode envelope: %v (raw=%q)", err, stdout.String())
|
||
}
|
||
return env.Data
|
||
}
|
||
|
||
// findCall returns the recorded call whose name (element[1]) and first arg
|
||
// (element[2]) match, or nil if none.
|
||
func findCall(calls [][]string, name, firstArg string) []string {
|
||
for _, c := range calls {
|
||
if len(c) >= 3 && c[1] == name && c[2] == firstArg {
|
||
return c
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// findCallArg returns the first recorded call whose name (element[1]) matches
|
||
// and whose args contain the given ordered subsequence anywhere after the name.
|
||
func findCallArg(calls [][]string, name string, wantArgs ...string) []string {
|
||
for _, c := range calls {
|
||
if len(c) < 2 || c[1] != name {
|
||
continue
|
||
}
|
||
args := c[2:]
|
||
i := 0
|
||
for _, a := range args {
|
||
if i < len(wantArgs) && a == wantArgs[i] {
|
||
i++
|
||
}
|
||
}
|
||
if i == len(wantArgs) {
|
||
return c
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func containsAll(call []string, subs ...string) bool {
|
||
set := map[string]bool{}
|
||
for _, c := range call {
|
||
set[c] = true
|
||
}
|
||
for _, s := range subs {
|
||
if !set[s] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// --- orchestration tests ---
|
||
|
||
func TestRunScaffold_EmptyRepo(t *testing.T) {
|
||
// Both a truly empty tree and a tree carrying only the seed README.md count
|
||
// as empty and must take the `app init` path.
|
||
for _, ls := range []string{"", "README.md\n"} {
|
||
t.Run("ls="+ls, func(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}}
|
||
withFakeRunner(t, f)
|
||
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||
if err != nil || kind != "init" {
|
||
t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err)
|
||
}
|
||
c := findCall(f.calls, "npx", "-y")
|
||
if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") {
|
||
t.Errorf("app init not invoked with expected args: %v", f.calls)
|
||
}
|
||
if c != nil && containsAll(c, "--local") {
|
||
t.Errorf("app init must NOT carry --local: %v", c)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) {
|
||
dir := t.TempDir() // no steering dir, no meta.json
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||
withFakeRunner(t, f)
|
||
kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack")
|
||
if err != nil || kind != "upgrade" {
|
||
t.Fatalf("kind=%q err=%v, want upgrade", kind, err)
|
||
}
|
||
if c := findCallArg(f.calls, "npx", "app", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online") {
|
||
t.Error("app sync not invoked with --prefer-online")
|
||
} else if containsAll(c, "--local") {
|
||
t.Errorf("app sync must NOT carry --local: %v", c)
|
||
}
|
||
if c := findCallArg(f.calls, "npx", "skills", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online", "--local") {
|
||
t.Error("skills sync should run with --prefer-online and --local when steering dir absent")
|
||
}
|
||
}
|
||
|
||
func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) {
|
||
dir := t.TempDir()
|
||
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}}
|
||
withFakeRunner(t, f)
|
||
if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
|
||
t.Error("skills sync must be skipped when steering dir exists")
|
||
}
|
||
}
|
||
|
||
func TestRunScaffold_AppInitFailure(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git ls-files": {stdout: ""},
|
||
"npx -y": {stderr: "boom", err: errors.New("exit 1")},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil {
|
||
t.Error("app init failure must propagate")
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""}, // empty repo -> app init
|
||
"git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["scaffold"] != "init" {
|
||
t.Errorf("scaffold=%v, want init", data["scaffold"])
|
||
}
|
||
if data["committed"] != true || data["pushed"] != true {
|
||
t.Errorf("committed/pushed = %v/%v, want true/true", data["committed"], data["pushed"])
|
||
}
|
||
if _, ok := data["npx_skipped"]; ok {
|
||
t.Error("npx_skipped must be removed")
|
||
}
|
||
// --template is omitted here, so resolveTemplate falls back to
|
||
// defaultTemplate and `app init` must still receive --template nestjs-react-fullstack.
|
||
c := findCall(f.calls, "npx", "-y")
|
||
if c == nil {
|
||
t.Error("npx scaffold not invoked")
|
||
} else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") {
|
||
t.Errorf("app init missing expected --template fallback args: %v", c)
|
||
} else if containsAll(c, "--local") {
|
||
t.Errorf("app init must NOT carry --local: %v", c)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||
dir := relCloneDir(t)
|
||
abs, err := filepath.Abs(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["scaffold"] != "already_initialized" {
|
||
t.Errorf("scaffold=%v, want already_initialized", data["scaffold"])
|
||
}
|
||
// short-circuit must still skip clone/checkout/scaffold/commit ...
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "git", "clone") || containsAll(c, "git", "checkout") || containsAll(c, "git", "status") {
|
||
t.Errorf("short-circuit must not run git clone/checkout/status; got %v", f.calls)
|
||
}
|
||
}
|
||
// ... but now refreshes local env exactly once.
|
||
envPullCalls := 0
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "+env-pull") {
|
||
envPullCalls++
|
||
}
|
||
}
|
||
if envPullCalls != 1 {
|
||
t.Errorf("short-circuit must call +env-pull exactly once; got %d (%v)", envPullCalls, f.calls)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||
dir := relCloneDir(t)
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f := &fakeCommandRunner{}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatal("mismatched app_id must error")
|
||
}
|
||
problem := requireAppsValidationProblem(t, err)
|
||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||
}
|
||
var ve *errs.ValidationError
|
||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||
}
|
||
if !strings.Contains(problem.Message, "different app") {
|
||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||
}
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""}, // empty repo -> app init scaffold
|
||
"git status": {}, // clean tree after scaffold -> no commit/push
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["committed"] != false {
|
||
t.Errorf("committed = %v, want false", data["committed"])
|
||
}
|
||
if data["pushed"] != false {
|
||
t.Errorf("pushed = %v, want false", data["pushed"])
|
||
}
|
||
if data["scaffold"] != "init" {
|
||
t.Errorf("scaffold = %v, want init", data["scaffold"])
|
||
}
|
||
if _, ok := data["npx_skipped"]; ok {
|
||
t.Error("npx_skipped must be removed")
|
||
}
|
||
if data["repository_url"] != "http://***@h/app_x.git" {
|
||
t.Errorf("repository_url = %v, want redacted http://***@h/app_x.git", data["repository_url"])
|
||
}
|
||
clone := findCall(f.calls, "git", "clone")
|
||
if clone == nil {
|
||
t.Fatalf("git clone not recorded; calls=%v", f.calls)
|
||
}
|
||
// clone == [dir, "git", "clone", "--", repoURL, dir]; "--" must precede the URL.
|
||
found := false
|
||
for i := 0; i+1 < len(clone); i++ {
|
||
if clone[i] == "--" && strings.HasPrefix(clone[i+1], "http") {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("git clone args missing \"--\" immediately before URL: %v", clone)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_DirtyTreeCommitPush(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: "src/x.ts\n"}, // non-empty repo -> app sync scaffold
|
||
"git status": {stdout: " M file.txt"},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if findCall(f.calls, "git", "add") == nil {
|
||
t.Errorf("git add not recorded; calls=%v", f.calls)
|
||
}
|
||
if commit := findCall(f.calls, "git", "commit"); commit == nil {
|
||
t.Errorf("git commit not recorded; calls=%v", f.calls)
|
||
} else if !containsAll(commit, "--no-verify") {
|
||
t.Errorf("git commit missing --no-verify; got %v", commit)
|
||
}
|
||
if findCall(f.calls, "git", "push") == nil {
|
||
t.Errorf("git push not recorded; calls=%v", f.calls)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["committed"] != true {
|
||
t.Errorf("committed = %v, want true", data["committed"])
|
||
}
|
||
if data["pushed"] != true {
|
||
t.Errorf("pushed = %v, want true", data["pushed"])
|
||
}
|
||
if data["scaffold"] != "upgrade" {
|
||
t.Errorf("scaffold = %v, want upgrade", data["scaffold"])
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_CredentialInitFailure(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": {stderr: "boom", err: errors.New("exit 1")},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil")
|
||
}
|
||
if strings.Contains(err.Error(), ":t@") {
|
||
t.Errorf("error leaks token: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_BadRepoURLScheme(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("ext::sh -c id"),
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil")
|
||
}
|
||
if findCall(f.calls, "git", "clone") != nil {
|
||
t.Errorf("git clone should not be recorded for bad scheme; calls=%v", f.calls)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_CloneFailure(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/r.git"),
|
||
"git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil")
|
||
}
|
||
if strings.Contains(err.Error(), "u:t@") {
|
||
t.Errorf("error leaks credentials: %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "***") {
|
||
t.Errorf("error should be redacted with ***: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_PushFailure(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {stdout: " M file.txt"},
|
||
"git push": {err: errors.New("exit 1")},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil")
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_DirNonEmpty(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
|
||
// Create a non-empty directory under cwd (SafeInputPath requires relative,
|
||
// cwd-contained paths), then pass it as --dir.
|
||
cwd, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
nonEmpty, err := os.MkdirTemp(cwd, "init-nonempty-")
|
||
if err != nil {
|
||
t.Fatalf("mkdirtemp: %v", err)
|
||
}
|
||
t.Cleanup(func() { os.RemoveAll(nonEmpty) })
|
||
if err := os.WriteFile(filepath.Join(nonEmpty, "x.txt"), []byte("x"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
|
||
err = runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", filepath.Base(nonEmpty), "--as", "user"}, factory, stdout)
|
||
if err == nil {
|
||
t.Fatalf("expected validation error, got nil")
|
||
}
|
||
if len(f.calls) != 0 {
|
||
t.Errorf("no runner calls expected before dir rejection; calls=%v", f.calls)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_AsPassthrough(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
|
||
// AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use
|
||
// --as user and assert it is forwarded to the self-invoked credential-init.
|
||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
var cred []string
|
||
for _, c := range f.calls {
|
||
if len(c) >= 3 && c[2] == "apps" {
|
||
cred = c
|
||
break
|
||
}
|
||
}
|
||
if cred == nil {
|
||
t.Fatalf("credential-init call not recorded; calls=%v", f.calls)
|
||
}
|
||
hasAs, hasUser := false, false
|
||
for _, a := range cred {
|
||
if a == "--as" {
|
||
hasAs = true
|
||
}
|
||
if a == "user" {
|
||
hasUser = true
|
||
}
|
||
}
|
||
if !hasAs || !hasUser {
|
||
t.Errorf("credential-init args missing --as user: %v", cred)
|
||
}
|
||
}
|
||
|
||
func TestEnsureMetaAppID(t *testing.T) {
|
||
// no meta.json -> no-op, must not create
|
||
dir := t.TempDir()
|
||
if err := ensureMetaAppID(dir, "app_x"); err != nil {
|
||
t.Fatalf("missing meta should be no-op: %v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(dir, metaRelPath)); !os.IsNotExist(err) {
|
||
t.Error("must not create meta.json when absent")
|
||
}
|
||
// exists without app_id -> add, preserve other fields
|
||
dir2 := t.TempDir()
|
||
os.MkdirAll(filepath.Join(dir2, ".spark"), 0o755)
|
||
os.WriteFile(filepath.Join(dir2, metaRelPath), []byte(`{"name":"keep"}`), 0o644)
|
||
if err := ensureMetaAppID(dir2, "app_x"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var m map[string]interface{}
|
||
b, _ := os.ReadFile(filepath.Join(dir2, metaRelPath))
|
||
json.Unmarshal(b, &m)
|
||
if m["app_id"] != "app_x" || m["name"] != "keep" {
|
||
t.Errorf("merge failed: %v", m)
|
||
}
|
||
// exists with app_id -> untouched
|
||
dir3 := t.TempDir()
|
||
os.MkdirAll(filepath.Join(dir3, ".spark"), 0o755)
|
||
os.WriteFile(filepath.Join(dir3, metaRelPath), []byte(`{"app_id":"orig"}`), 0o644)
|
||
if err := ensureMetaAppID(dir3, "app_x"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, _ = os.ReadFile(filepath.Join(dir3, metaRelPath))
|
||
m = nil
|
||
json.Unmarshal(b, &m)
|
||
if m["app_id"] != "orig" {
|
||
t.Errorf("existing app_id overwritten: %v", m)
|
||
}
|
||
}
|
||
|
||
func TestHasSteeringSkills(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if hasSteeringSkills(dir) {
|
||
t.Error("absent steering dir -> false")
|
||
}
|
||
os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755)
|
||
if !hasSteeringSkills(dir) {
|
||
t.Error("present steering dir -> true")
|
||
}
|
||
}
|
||
|
||
func TestIsEmptyRepo(t *testing.T) {
|
||
cases := []struct {
|
||
name, ls string
|
||
want bool
|
||
}{
|
||
{"zero files", "", true},
|
||
{"only README.md", "README.md\n", true},
|
||
{"README + business file", "README.md\nsrc/x.ts\n", false},
|
||
{"business file only", "src/x.ts\n", false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: c.ls}}}
|
||
withFakeRunner(t, f)
|
||
got, err := isEmptyRepo(context.Background(), t.TempDir())
|
||
if err != nil || got != c.want {
|
||
t.Errorf("ls=%q -> empty=%v err=%v, want %v", c.ls, got, err, c.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns
|
||
// the stderr buffer, so tests can assert on the +init progress log lines that
|
||
// initLogf writes to IO().ErrOut.
|
||
func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||
t.Helper()
|
||
t.Setenv("HOME", t.TempDir())
|
||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||
cfg := &core.CliConfig{
|
||
AppID: "test-app-" + strings.ToLower(t.Name()),
|
||
AppSecret: "test-secret",
|
||
Brand: core.BrandFeishu,
|
||
UserOpenId: "ou_test",
|
||
}
|
||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||
return factory, stdout, stderr
|
||
}
|
||
|
||
func TestAppsInit_Req1_Wording(t *testing.T) {
|
||
var tmpl *common.Flag
|
||
for i := range AppsInit.Flags {
|
||
if AppsInit.Flags[i].Name == "template" {
|
||
tmpl = &AppsInit.Flags[i]
|
||
}
|
||
}
|
||
if tmpl == nil {
|
||
t.Fatal("--template flag missing")
|
||
}
|
||
if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") {
|
||
t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc)
|
||
}
|
||
if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") {
|
||
t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc)
|
||
}
|
||
|
||
// The --dry-run output is a flat object (DryRunAPI marshals to top-level keys
|
||
// description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout
|
||
// directly rather than via parseEnvelopeData.
|
||
factory, stdout, _ := newAppsExecuteFactoryWithStderr(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||
t.Fatalf("dry-run err=%v", err)
|
||
}
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(stdout.Bytes(), &data); err != nil {
|
||
t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String())
|
||
}
|
||
desc, _ := data["description"].(string)
|
||
if strings.Contains(strings.ToLower(desc), "scaffold") {
|
||
t.Errorf("dry-run description still mentions scaffold: %q", desc)
|
||
}
|
||
scaffold, ok := data["scaffold"].(string)
|
||
if !ok {
|
||
t.Error("dry-run must keep machine-contract key `scaffold`")
|
||
} else if !strings.Contains(scaffold, "skills sync --local") {
|
||
t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold)
|
||
} else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") ||
|
||
strings.Contains(scaffold, "app sync --local") {
|
||
t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold)
|
||
}
|
||
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil {
|
||
t.Fatalf("run err=%v", err)
|
||
}
|
||
if strings.Contains(stderr2.String(), "Scaffolding") {
|
||
t.Errorf("progress log still says Scaffolding: %q", stderr2.String())
|
||
}
|
||
if !strings.Contains(stderr2.String(), "Initializing app code") {
|
||
t.Errorf("progress log should say 'Initializing app code': %q", stderr2.String())
|
||
}
|
||
}
|
||
|
||
func TestClassifyPorcelain(t *testing.T) {
|
||
cases := []struct {
|
||
name, status string
|
||
wantAppCode, wantConfig bool
|
||
}{
|
||
{"empty", "", false, false},
|
||
{"app code only", " M src/x.ts\n?? package.json\n", true, false},
|
||
{"config only", "?? .spark/meta.json\n?? .agent/skills/steering/x.md\n", false, true},
|
||
{"both", " M src/x.ts\n?? .spark/meta.json\n", true, true},
|
||
{"rename to config", "R old.txt -> .spark/meta.json\n", false, true},
|
||
{"rename to app code", "R .spark/old -> src/new.ts\n", true, false},
|
||
{"quoted config path", "?? \".spark/with space.json\"\n", false, true},
|
||
{"spark prefix lookalike not config", "?? .sparkrc\n", true, false},
|
||
{"exact .spark dir", "?? .spark\n", false, true},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
gotApp, gotCfg := classifyPorcelain(c.status)
|
||
if (len(gotApp) > 0) != c.wantAppCode || (len(gotCfg) > 0) != c.wantConfig {
|
||
t.Errorf("classifyPorcelain(%q) = (app=%v,cfg=%v), want app=%v cfg=%v",
|
||
c.status, gotApp, gotCfg, c.wantAppCode, c.wantConfig)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// commitMessages returns the -m messages of all recorded `git commit` calls.
|
||
func commitMessages(calls [][]string) []string {
|
||
var msgs []string
|
||
for _, c := range calls {
|
||
if len(c) >= 3 && c[1] == "git" && c[2] == "commit" {
|
||
for i := 3; i+1 < len(c); i++ {
|
||
if c[i] == "-m" {
|
||
msgs = append(msgs, c[i+1])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return msgs
|
||
}
|
||
|
||
func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
msgs := commitMessages(f.calls)
|
||
want := []string{"chore: initialize app project code", "chore: initialize app config"}
|
||
if len(msgs) != 2 || msgs[0] != want[0] || msgs[1] != want[1] {
|
||
t.Fatalf("commit messages = %v, want %v", msgs, want)
|
||
}
|
||
// The split's core invariant: each commit stages its own group's exact
|
||
// porcelain paths (no :(exclude) magic, no explicitly-named ignored dirs —
|
||
// see TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir). The app-code commit
|
||
// stages src/app.ts and not .spark/meta.json; the config commit, the reverse.
|
||
appAdd := findCallArg(f.calls, "git", "add", "-A", "--", "src/app.ts")
|
||
if appAdd == nil {
|
||
t.Errorf("app-code git add missing src/app.ts; calls=%v", f.calls)
|
||
} else if containsAll(appAdd, ".spark/meta.json") {
|
||
t.Errorf("app-code commit must not stage config paths; got %v", appAdd)
|
||
}
|
||
cfgAdd := findCallArg(f.calls, "git", "add", "-A", "--", ".spark/meta.json")
|
||
if cfgAdd == nil {
|
||
t.Errorf("config git add missing .spark/meta.json; calls=%v", f.calls)
|
||
} else if containsAll(cfgAdd, "src/app.ts") {
|
||
t.Errorf("config commit must not stage app code; got %v", cfgAdd)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["committed"] != true || data["pushed"] != true {
|
||
t.Errorf("committed/pushed = %v/%v, want true/true", data["committed"], data["pushed"])
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {stdout: " A src/app.ts\n"},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
msgs := commitMessages(f.calls)
|
||
if len(msgs) != 1 || msgs[0] != "chore: initialize app project code" {
|
||
t.Fatalf("commit messages = %v, want one app-code commit", msgs)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {stdout: " A .spark/meta.json\n"},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
msgs := commitMessages(f.calls)
|
||
if len(msgs) != 1 || msgs[0] != "chore: initialize app config" {
|
||
t.Fatalf("commit messages = %v, want one config commit", msgs)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: "src/x.ts\n"},
|
||
"git status": {stdout: " M file.txt\n M .spark/meta.json\n"},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected: %v", err)
|
||
}
|
||
msgs := commitMessages(f.calls)
|
||
if len(msgs) != 1 || msgs[0] != "chore: initialize app repository" {
|
||
t.Fatalf("commit messages = %v, want one upgrade commit", msgs)
|
||
}
|
||
for _, c := range f.calls {
|
||
if len(c) >= 3 && c[1] == "git" && c[2] == "commit" && !containsAll(c, "--no-verify") {
|
||
t.Errorf("commit missing --no-verify: %v", c)
|
||
}
|
||
}
|
||
}
|
||
|
||
// gitMust runs a git command in dir with a real binary, failing the test on error.
|
||
func gitMust(t *testing.T, dir string, args ...string) string {
|
||
t.Helper()
|
||
cmd := exec.Command("git", args...)
|
||
cmd.Dir = dir
|
||
out, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out)
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir exercises the empty-repo
|
||
// commit split against a REAL git repo whose scaffold gitignores .agent. This
|
||
// reproduces the production failure where `git add -- .spark .agent` errored on
|
||
// the ignored .agent path; the fix stages the config remainder with ".".
|
||
func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) {
|
||
if _, err := exec.LookPath("git"); err != nil {
|
||
t.Skip("git not available")
|
||
}
|
||
// Bare remote so `git push origin sprint/default` succeeds.
|
||
remote := t.TempDir()
|
||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||
|
||
dir := t.TempDir()
|
||
gitMust(t, dir, "init", "-q", "--initial-branch", defaultInitBranch)
|
||
gitMust(t, dir, "config", "user.email", "t@example.com")
|
||
gitMust(t, dir, "config", "user.name", "Test")
|
||
gitMust(t, dir, "remote", "add", "origin", remote)
|
||
|
||
// Scaffold: app code + .spark config + an IGNORED .agent dir.
|
||
mustWrite(t, filepath.Join(dir, ".gitignore"), ".agent\n")
|
||
if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, "src", "x.ts"), "export const x = 1\n")
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, ".spark", "meta.json"), `{"app_id":"app_x"}`)
|
||
if err := os.MkdirAll(filepath.Join(dir, ".agent"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, ".agent", "skill.md"), "ignored\n")
|
||
|
||
// Use the real exec runner (not the fake) so gitignore semantics apply.
|
||
orig := initRunner
|
||
initRunner = execCommandRunner{}
|
||
t.Cleanup(func() { initRunner = orig })
|
||
|
||
committed, pushed, err := commitAndPushIfDirty(context.Background(), dir, scaffoldKindInit)
|
||
if err != nil {
|
||
t.Fatalf("commitAndPushIfDirty returned error: %v", err)
|
||
}
|
||
if !committed || !pushed {
|
||
t.Fatalf("committed=%v pushed=%v, want true/true", committed, pushed)
|
||
}
|
||
|
||
// Two commits, newest first: config then app code.
|
||
subjects := strings.Split(strings.TrimSpace(gitMust(t, dir, "log", "--format=%s", "-2")), "\n")
|
||
want := []string{commitMsgAppConfig, commitMsgAppCode}
|
||
if len(subjects) != 2 || subjects[0] != want[0] || subjects[1] != want[1] {
|
||
t.Fatalf("commit subjects = %v, want %v", subjects, want)
|
||
}
|
||
|
||
// .agent must NOT be tracked; .spark and src must be.
|
||
tracked := gitMust(t, dir, "ls-files")
|
||
if strings.Contains(tracked, ".agent") {
|
||
t.Errorf("ignored .agent must not be committed; tracked=%q", tracked)
|
||
}
|
||
if !strings.Contains(tracked, ".spark/meta.json") {
|
||
t.Errorf(".spark/meta.json should be committed; tracked=%q", tracked)
|
||
}
|
||
if !strings.Contains(tracked, "src/x.ts") {
|
||
t.Errorf("src/x.ts should be committed; tracked=%q", tracked)
|
||
}
|
||
}
|
||
|
||
func mustWrite(t *testing.T, path, content string) {
|
||
t.Helper()
|
||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
func envPullOK(envFile string) fakeCallResult {
|
||
return fakeCallResult{stdout: `{"ok":true,"data":{"env_file":"` + envFile + `"}}`}
|
||
}
|
||
|
||
// testRuntimeForEnvPull builds a minimal RuntimeContext exposing the --as flag,
|
||
// which is all pullEnv reads.
|
||
func testRuntimeForEnvPull(t *testing.T, as string) *common.RuntimeContext {
|
||
t.Helper()
|
||
cmd := &cobra.Command{Use: "init"}
|
||
cmd.Flags().String("as", as, "")
|
||
return common.TestNewRuntimeContext(cmd, nil)
|
||
}
|
||
|
||
func TestPullEnv(t *testing.T) {
|
||
// success: stdout envelope parsed; subprocess invoked with expected args
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK("/abs/app_x/.env.local")}}
|
||
withFakeRunner(t, f)
|
||
rctx := testRuntimeForEnvPull(t, "")
|
||
envFile, reason := pullEnv(context.Background(), rctx, "app_x", "/abs/app_x")
|
||
if reason != "" || envFile != "/abs/app_x/.env.local" {
|
||
t.Fatalf("success: envFile=%q reason=%q", envFile, reason)
|
||
}
|
||
// findCallArg matches c[1] against name; for self-invocations c[1] is the
|
||
// test binary path (unknown at compile time), so search the args slice
|
||
// directly for the expected ordered subsequence.
|
||
var c []string
|
||
for _, call := range f.calls {
|
||
if findCallArg([][]string{call}, call[1], "apps", "+env-pull", "--app-id", "app_x", "--project-path", "/abs/app_x", "--format", "json") != nil {
|
||
c = call
|
||
break
|
||
}
|
||
}
|
||
if c == nil {
|
||
t.Errorf("+env-pull not invoked with expected args: %v", f.calls)
|
||
}
|
||
|
||
// failure: non-zero exit + stderr error envelope -> reason, env_file empty
|
||
f2 := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": {
|
||
stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`,
|
||
err: fmt.Errorf("exit status 2"),
|
||
}}}
|
||
withFakeRunner(t, f2)
|
||
envFile2, reason2 := pullEnv(context.Background(), testRuntimeForEnvPull(t, ""), "app_x", "/abs/app_x")
|
||
if envFile2 != "" || reason2 != "missing_scope: need spark:app:read" {
|
||
t.Fatalf("failure: envFile=%q reason=%q", envFile2, reason2)
|
||
}
|
||
}
|
||
|
||
// TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade pins down that the non-empty
|
||
// (upgrade) path is unaffected by the commit-split / exact-path changes: it must
|
||
// stay a SINGLE commit using `git add -A -- .`, which silently skips a gitignored
|
||
// .agent (no ignored-path error), with the upgrade subject.
|
||
func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) {
|
||
if _, err := exec.LookPath("git"); err != nil {
|
||
t.Skip("git not available")
|
||
}
|
||
remote := t.TempDir()
|
||
gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch)
|
||
|
||
dir := t.TempDir()
|
||
gitMust(t, dir, "init", "-q", "--initial-branch", defaultInitBranch)
|
||
gitMust(t, dir, "config", "user.email", "t@example.com")
|
||
gitMust(t, dir, "config", "user.name", "Test")
|
||
gitMust(t, dir, "remote", "add", "origin", remote)
|
||
|
||
// Existing (non-empty) repo: a committed baseline with .agent already ignored.
|
||
mustWrite(t, filepath.Join(dir, ".gitignore"), ".agent\n")
|
||
if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, "src", "old.ts"), "export const old = 0\n")
|
||
gitMust(t, dir, "add", "-A")
|
||
gitMust(t, dir, "commit", "-q", "-m", "baseline")
|
||
baseline := strings.TrimSpace(gitMust(t, dir, "rev-parse", "HEAD"))
|
||
|
||
// Simulate `app sync`: a modified app file, a patched .spark config, and an
|
||
// IGNORED .agent dir produced by `skills sync`.
|
||
mustWrite(t, filepath.Join(dir, "src", "old.ts"), "export const old = 1\n")
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, ".spark", "meta.json"), `{"app_id":"app_x"}`)
|
||
if err := os.MkdirAll(filepath.Join(dir, ".agent"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mustWrite(t, filepath.Join(dir, ".agent", "skill.md"), "ignored\n")
|
||
|
||
orig := initRunner
|
||
initRunner = execCommandRunner{}
|
||
t.Cleanup(func() { initRunner = orig })
|
||
|
||
committed, pushed, err := commitAndPushIfDirty(context.Background(), dir, scaffoldKindUpgrade)
|
||
if err != nil {
|
||
t.Fatalf("commitAndPushIfDirty returned error: %v", err)
|
||
}
|
||
if !committed || !pushed {
|
||
t.Fatalf("committed=%v pushed=%v, want true/true", committed, pushed)
|
||
}
|
||
|
||
// Exactly ONE commit added, with the upgrade subject (not a split).
|
||
added := strings.TrimSpace(gitMust(t, dir, "rev-list", "--count", baseline+"..HEAD"))
|
||
if added != "1" {
|
||
t.Fatalf("upgrade path added %s commits, want exactly 1 (no split)", added)
|
||
}
|
||
if subj := strings.TrimSpace(gitMust(t, dir, "log", "--format=%s", "-1")); subj != commitMsgUpgrade {
|
||
t.Errorf("upgrade commit subject = %q, want %q", subj, commitMsgUpgrade)
|
||
}
|
||
|
||
// .agent stays ignored; the real changes are committed.
|
||
tracked := gitMust(t, dir, "ls-files")
|
||
if strings.Contains(tracked, ".agent") {
|
||
t.Errorf("ignored .agent must not be committed; tracked=%q", tracked)
|
||
}
|
||
if !strings.Contains(tracked, ".spark/meta.json") {
|
||
t.Errorf(".spark/meta.json should be committed; tracked=%q", tracked)
|
||
}
|
||
}
|
||
|
||
func TestEnsureEmptyDir_RejectsNonDirAndNonEmpty(t *testing.T) {
|
||
t.Run("non-existent is ok", func(t *testing.T) {
|
||
if err := ensureEmptyDir(filepath.Join(t.TempDir(), "nope")); err != nil {
|
||
t.Errorf("non-existent dir should be ok, got %v", err)
|
||
}
|
||
})
|
||
t.Run("file is rejected", func(t *testing.T) {
|
||
f := filepath.Join(t.TempDir(), "afile")
|
||
if err := os.WriteFile(f, []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ensureEmptyDir(f); err == nil {
|
||
t.Error("a regular file must be rejected")
|
||
}
|
||
})
|
||
t.Run("non-empty dir is rejected", func(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := os.WriteFile(filepath.Join(dir, "child"), []byte("x"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ensureEmptyDir(dir); err == nil {
|
||
t.Error("a non-empty dir must be rejected")
|
||
}
|
||
})
|
||
t.Run("empty dir is ok", func(t *testing.T) {
|
||
if err := ensureEmptyDir(t.TempDir()); err != nil {
|
||
t.Errorf("empty dir should be ok, got %v", err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestParseEnvFileFromEnvelope(t *testing.T) {
|
||
got, err := parseEnvFileFromEnvelope(`{"ok":true,"data":{"env_file":"/abs/app_x/.env.local"}}`)
|
||
if err != nil || got != "/abs/app_x/.env.local" {
|
||
t.Fatalf("got %q err %v", got, err)
|
||
}
|
||
for _, in := range []string{``, `not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"env_file":""}}`} {
|
||
if _, err := parseEnvFileFromEnvelope(in); err == nil {
|
||
t.Errorf("expected error for %q", in)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseEnvPullErrorEnvelope(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{`{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`, "missing_scope: need spark:app:read"},
|
||
{`{"ok":false,"error":{"message":"boom"}}`, "boom"},
|
||
{`not json`, ""},
|
||
{`{"ok":false,"error":{}}`, ""},
|
||
{``, ""},
|
||
}
|
||
for _, c := range cases {
|
||
if got := parseEnvPullErrorEnvelope(c.in); got != c.want {
|
||
t.Errorf("parseEnvPullErrorEnvelope(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestEnsureMetaAppID_MalformedJSON(t *testing.T) {
|
||
dir := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte("{not json"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := ensureMetaAppID(dir, "app_x"); err == nil {
|
||
t.Error("malformed meta.json must return a parse error")
|
||
}
|
||
}
|
||
|
||
func TestIsEmptyRepo_GitError(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git ls-files": {err: errors.New("fatal: not a git repository")},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
if _, err := isEmptyRepo(context.Background(), t.TempDir()); err == nil {
|
||
t.Error("git ls-files failure must surface as an error")
|
||
}
|
||
}
|
||
|
||
func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) {
|
||
// Non-empty repo takes the `app sync` path; make that npx call fail.
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git ls-files": {stdout: "src/x.ts\n"},
|
||
"npx -y": {err: errors.New("sync boom")},
|
||
}})
|
||
if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil {
|
||
t.Error("npx app sync failure must surface as an error")
|
||
}
|
||
}
|
||
|
||
func TestStageAndCommit_Errors(t *testing.T) {
|
||
t.Run("git add fails", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git add": {err: errors.New("boom")},
|
||
}})
|
||
if err := stageAndCommit(context.Background(), t.TempDir(), "msg", "."); err == nil {
|
||
t.Error("git add failure must surface as an error")
|
||
}
|
||
})
|
||
t.Run("git commit fails", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git commit": {err: errors.New("boom")},
|
||
}})
|
||
if err := stageAndCommit(context.Background(), t.TempDir(), "msg", "."); err == nil {
|
||
t.Error("git commit failure must surface as an error")
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestCommitAndPushIfDirty_Branches(t *testing.T) {
|
||
t.Run("clean tree is a no-op", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git status": {stdout: " "},
|
||
}})
|
||
committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade)
|
||
if err != nil || committed || pushed {
|
||
t.Errorf("clean tree: got committed=%v pushed=%v err=%v, want false,false,nil", committed, pushed, err)
|
||
}
|
||
})
|
||
t.Run("status error", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git status": {err: errors.New("boom")},
|
||
}})
|
||
if _, _, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade); err == nil {
|
||
t.Error("git status failure must surface as an error")
|
||
}
|
||
})
|
||
t.Run("upgrade path commits and pushes", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git status": {stdout: " M src/app.ts\n"},
|
||
}})
|
||
committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade)
|
||
if err != nil || !committed || !pushed {
|
||
t.Errorf("dirty upgrade: got committed=%v pushed=%v err=%v, want true,true,nil", committed, pushed, err)
|
||
}
|
||
})
|
||
t.Run("push failure", func(t *testing.T) {
|
||
withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git status": {stdout: " M src/app.ts\n"},
|
||
"git push": {err: errors.New("rejected")},
|
||
}})
|
||
committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade)
|
||
if err == nil || !committed || pushed {
|
||
t.Errorf("push failure: got committed=%v pushed=%v err=%v, want true,false,err", committed, pushed, err)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestAppsInit_EnvPull_Success(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {},
|
||
"env-pull": envPullOK("/abs/app_x/.env.local"),
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["env_pulled"] != true {
|
||
t.Errorf("env_pulled = %v, want true", data["env_pulled"])
|
||
}
|
||
if data["env_file"] != "/abs/app_x/.env.local" {
|
||
t.Errorf("env_file = %v", data["env_file"])
|
||
}
|
||
// env-pull invoked with forwarded --as user and the expected flags
|
||
var ep []string
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "+env-pull") {
|
||
ep = c
|
||
break
|
||
}
|
||
}
|
||
if ep == nil || !containsAll(ep, "--app-id", "app_x", "--project-path", "--as", "user", "--format", "json") {
|
||
t.Errorf("+env-pull not invoked with expected args: %v", f.calls)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_EnvPull_NonFatal(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||
"git clone": {},
|
||
"git checkout": {},
|
||
"git ls-files": {stdout: ""},
|
||
"git status": {},
|
||
"env-pull": {
|
||
stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`,
|
||
err: fmt.Errorf("exit status 2"),
|
||
},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["env_pulled"] != false {
|
||
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
|
||
}
|
||
if data["env_pull_error"] != "missing_scope: need spark:app:read" {
|
||
t.Errorf("env_pull_error = %v", data["env_pull_error"])
|
||
}
|
||
if _, ok := data["env_file"]; ok {
|
||
t.Errorf("env_file must be absent on failure: %v", data["env_file"])
|
||
}
|
||
msg, _ := data["message"].(string)
|
||
if !strings.Contains(msg, "+env-pull --app-id app_x") {
|
||
t.Errorf("message missing retry hint: %q", msg)
|
||
}
|
||
if strings.Contains(stdout.String(), "u:t@h") {
|
||
t.Errorf("raw credential leaked: %s", stdout.String())
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) {
|
||
dir := relCloneDir(t)
|
||
abs, err := filepath.Abs(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
envFile := filepath.Join(abs, ".env.local")
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
called := false
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "+env-pull") {
|
||
called = true
|
||
}
|
||
}
|
||
if !called {
|
||
t.Errorf("already-initialized path must call +env-pull: %v", f.calls)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["scaffold"] != "already_initialized" {
|
||
t.Errorf("scaffold=%v, want already_initialized", data["scaffold"])
|
||
}
|
||
if data["env_pulled"] != true {
|
||
t.Errorf("env_pulled=%v, want true", data["env_pulled"])
|
||
}
|
||
if data["env_file"] != envFile {
|
||
t.Errorf("env_file=%v, want %v", data["env_file"], envFile)
|
||
}
|
||
if data["committed"] != false || data["pushed"] != false {
|
||
t.Errorf("committed/pushed must stay false: %v", data)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) {
|
||
dir := relCloneDir(t)
|
||
abs, err := filepath.Abs(dir)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"env-pull": {
|
||
stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`,
|
||
err: fmt.Errorf("exit status 2"),
|
||
},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
|
||
t.Fatalf("env-pull failure must be non-fatal, got: %v", err)
|
||
}
|
||
data := parseEnvelopeData(t, stdout)
|
||
if data["scaffold"] != "already_initialized" {
|
||
t.Errorf("scaffold=%v, want already_initialized", data["scaffold"])
|
||
}
|
||
if data["env_pulled"] != false {
|
||
t.Errorf("env_pulled=%v, want false", data["env_pulled"])
|
||
}
|
||
if data["env_pull_error"] != "missing_scope: need spark:app:read" {
|
||
t.Errorf("env_pull_error=%v", data["env_pull_error"])
|
||
}
|
||
if _, ok := data["env_file"]; ok {
|
||
t.Errorf("env_file must be absent on failure: %v", data["env_file"])
|
||
}
|
||
msg, _ := data["message"].(string)
|
||
if !strings.Contains(msg, "+env-pull --app-id app_x") {
|
||
t.Errorf("message missing retry hint: %q", msg)
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) {
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{}}
|
||
withFakeRunner(t, f)
|
||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||
dir := relCloneDir(t)
|
||
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
var m map[string]interface{}
|
||
if err := json.Unmarshal(stdout.Bytes(), &m); err != nil {
|
||
t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String())
|
||
}
|
||
ep, _ := m["env_pull"].(string)
|
||
if !strings.Contains(ep, "+env-pull") {
|
||
t.Errorf("dry-run missing env_pull step: %v", m)
|
||
}
|
||
for _, c := range f.calls {
|
||
if containsAll(c, "+env-pull") {
|
||
t.Errorf("dry-run must not execute +env-pull: %v", f.calls)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||
if strings.Contains(strings.ToLower(AppsInit.Description), "local development repository") {
|
||
t.Errorf("Description should describe initializing app code, not a local dev repo: %q", AppsInit.Description)
|
||
}
|
||
if !strings.Contains(strings.ToLower(AppsInit.Description), "code") {
|
||
t.Errorf("Description should mention app code: %q", AppsInit.Description)
|
||
}
|
||
}
|
||
|
||
func TestReadMetaAppID(t *testing.T) {
|
||
writeMeta := func(t *testing.T, content string) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
// 不存在 meta.json → ("", false, nil)
|
||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||
}
|
||
// 存在且有 app_id → (app_id, true, nil)
|
||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||
}
|
||
// 存在但 app_id 空 → ("", true, nil)
|
||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||
}
|
||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||
}
|
||
}
|
||
|
||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||
writeMeta := func(t *testing.T, content string) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return dir
|
||
}
|
||
|
||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||
t.Fatalf("no meta should pass: %v", err)
|
||
}
|
||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||
}
|
||
|
||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||
if errMismatch == nil {
|
||
t.Fatal("different app should error")
|
||
}
|
||
if existing != "app_other" {
|
||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||
}
|
||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||
}
|
||
var ve *errs.ValidationError
|
||
if !errors.As(errMismatch, &ve) {
|
||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||
}
|
||
if ve.Param != "--dir" {
|
||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||
}
|
||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||
}
|
||
if !strings.Contains(problem.Hint, "different --dir") {
|
||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||
}
|
||
|
||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||
if errEmpty == nil {
|
||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||
}
|
||
if emptyExisting != "" {
|
||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||
}
|
||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||
}
|
||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||
}
|
||
if strings.Contains(pEmpty.Message, "different app") {
|
||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||
}
|
||
|
||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||
if errBad == nil {
|
||
t.Fatal("corrupted meta should fail closed")
|
||
}
|
||
if badExisting != "" {
|
||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||
}
|
||
pBad := requireAppsValidationProblem(t, errBad)
|
||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||
}
|
||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||
}
|
||
var veBad *errs.ValidationError
|
||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||
}
|
||
}
|
||
|
||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||
// classification of an external-tool failure: a failing git subprocess
|
||
// surfaces as internal/external_tool with the cause preserved.
|
||
func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) {
|
||
cause := errors.New("exit status 128")
|
||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||
"git ls-files": {stderr: "fatal: not a git repository", err: cause},
|
||
}}
|
||
withFakeRunner(t, f)
|
||
_, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack")
|
||
if err == nil {
|
||
t.Fatalf("expected error from failing git subprocess")
|
||
}
|
||
p, ok := errs.ProblemOf(err)
|
||
if !ok {
|
||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||
}
|
||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool {
|
||
t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype)
|
||
}
|
||
if !errors.Is(err, cause) {
|
||
t.Fatalf("cause chain not preserved: %v", err)
|
||
}
|
||
}
|