Files
larksuite__cli/shortcuts/apps/apps_init_test.go
anngo-nk 3c2cc273f7 feat(apps): design_html support, creative-design skill, unified TOS publish (#1901)
* feat(apps): add design_html app type support and credential author identity

- Add design_html to appTypePolicies (same as modern_html: skip install/env-pull/skills-sync)
- Route +html-publish via policy (useTOSPublish) instead of hardcoded type check
- Parse commit_author_name/commit_author_email from +git-credential-init response
- Use server-provided author identity for repo-local git config, fallback to defaults
- Support meta_token as identifier in +get command
- Use envvars.AgentName() for source_agent in +create (reads LARKSUITE_CLI_AGENT_NAME)
- Add creative HTML guide reference skeleton and SKILL.md routing entry
- Update git-credential skill docs with new output fields

* fix(apps): unify html-publish to TOS path, add html to init skip policy

- Remove useTOSPublish policy field, html-publish always uses TOS upload
- Add html type to appTypePolicies (skip install/env-pull/skills-sync)
- Remove design_html from policies (not yet in use)
- Fix git credential dry-run test for new local_effects entry

* feat(apps): validate --app-id format to reject meta_token with resolution hint

* feat(apps): integrate creative-design skill and update skill docs

- Add creative-design skill under lark-apps/ (same level as references/)
- Update SKILL.md description with creative design trigger keywords
- Add creative design routing in development path selection table
- Add --path relative path guidance in html-publish reference
- Remove old creative-html-guide skeleton (replaced by creative-design)

* feat(apps): skip app sync for html/modern_html in +init

Add skipAppSync policy field; html and modern_html skip npx app sync
on non-empty repo path since static HTML sites don't need it.

* fix(apps): merge creative-design into html routing and add intent entry

- Merge static HTML and creative-design into one path selection row
- Add creative-design intent routing entry before html-publish

* docs(apps): add html local dev flow, unify publish link source

- Add html端到端 flow in local-dev.md (create → init → dev → release-create)
- Unify publish link source: html and full_stack both use +release-get
- Update SKILL.md routing and publish护栏 accordingly

* fix(apps): update html-publish dry-run and skill docs for TOS flow

- DryRun shows actual 3-step TOS flow (pre_release → TOS PUT → release-create)
- Skill docs: output is release_id, use +release-get to poll for online_url
- Remove references to legacy multipart upload and data.url

* TEMP: pin miaoda-cli alpha and add BOE header for testing

- Pin miaoda-cli to 0.1.24-alpha.fb2cf0a (revert to @latest before merge)
- Add x-tt-env=boe_aily_lark_cli header globally (remove before merge)
- html app-type uses --template design-html instead of --app-type (remove before merge)

* docs(apps): add creative mode link format and meta_token recognition

- Add creative mode (html) link format `https://{tenant}/page/{meta_token}` in publish护栏
- Note dev and publish URLs are the same for creative mode, unlike full_stack
- Add meta_token to app_id resolution with full link format in app_id获取

* docs(apps): route html apps through local-dev git pipeline by default

- Select dev path: html apps now default to local-dev pipeline instead of skipping local/cloud axis
- Intent routing: creative-design publishes via local-dev flow instead of +html-publish
- Remove +html-publish fallback from local-dev "when not to use" section

* docs(apps): generalize skill references to cover both html and full_stack

Remove full_stack-only wording from init, create, list, env-pull, and
release-create references since html apps now share the same local dev
and release flow.

* feat(apps): add meta_token to +get pretty output and dry-run description

* docs(apps): unify html as creative mode, fix routing and local-dev flow

- Remove "HTML" as separate dev path; html and full_stack both go through local-dev
- Intent routing: read local-dev before creative-design to establish git pipeline first
- Mark +html-publish as legacy, redirect to local-dev for creative mode
- Split html local-dev into 3 scenarios: first-time, iteration, pre-generated files
- git add . instead of selective add to capture all creative-design output files

* docs(apps): remove dev link from html-publish output, only return release-get online_url

* fix: add license header to deck-stage.js

* docs(apps): clarify dev link only for full_stack, creative mode shares dev/pub URL

* docs(apps): remove +html-publish from intent routing, description, and guardrails

All HTML apps now go through local-dev pipeline. +html-publish is deprecated.

* docs(apps): remove html-publish references from create/release-create/cloud-dev pages

html-publish is no longer the recommended path for HTML apps; all html
and full_stack apps now follow the same local-dev + release-create flow.

* fix(apps): address PR review feedback

- html-publish dry-run: register all 3 API calls (GET pre_release, PUT TOS, POST release-create) instead of hiding steps in metadata
- validateRealAppID: remove cli_ prefix check (not a valid app_id prefix)
- E2E: update git-credential dry-run to expect 4 local_effects
- E2E: update html-publish dry-run to expect GET pre_release

* fix(apps): address PR review — remove legacy multipart dead code, fix docs

- Delete html_publish_client.go and html_publish_client_test.go (legacy multipart)
- Remove runHTMLPublish, enrichHTMLPublishAPIError, buildHTMLPublishFailureHint
- Migrate tests from runHTMLPublish to prepareHTMLPublishTarball (same coverage)
- Remove cli_ prefix from validateRealAppID (not a valid app_id prefix)
- Fix html-publish.md error wording to match actual message
- Register all 3 TOS API calls in html-publish dry-run
- Update E2E tests for new dry-run contract

* fix(apps): correctly merge SKILL.md with main (role mgmt, auth wording, source boundary)

Rebuild SKILL.md from our branch version, then merge in main's additions:
- description: add HTML静态站点发布, 应用角色与成员管理, 应用角色/角色成员
- 身份与授权: use main's updated wording (no proactive re-login)
- intent routing: add +role-* row, +init refs 平台资源与应用源码边界
- 能力边界 → 平台资源与应用源码边界 (7 rules from main)
- 禁止预授权底线: add role ② and html-publish ③ clauses

* docs(apps): route legacy html-publish only for non-git html apps

* docs(apps): strengthen local-dev routing and git recovery guidance

fix:cherry-pick and resolve conflicts

* fix: gofmt apps_errors.go and apps_errors_test.go

* docs(apps): strengthen git credential recovery and add file-upload guidance

- Generalize git error recovery: any git operation failure triggers
  +git-credential-init refresh, with environment analysis on failure
- Add resource file upload rule: use +file-upload instead of local
  paths, base64 inlining, or git commits; files are app-scoped

* test(apps): strengthen html-publish dry-run assertions for TOS 3-step contract

* fix: 文件资源上传

* docs(apps): update creative-design skill content

* fix: re-add license header to deck-stage.js

* refactor(apps): merge system-prompt.md into SKILL.md for creative-design skill

Consolidate the thin SKILL.md wrapper and the full system-prompt.md
methodology into a single file, eliminating an unnecessary indirection.
Update references in claude.md and codex.md accordingly.

* chore: revert TEMP changes — miaoda-cli back to @latest, remove BOE header

* docs(apps): remove 可见范围 from 发布态护栏

创意模式的可见范围权限走 lark-drive 文档权限体系,而非妙搭应用
权限体系,当前的 +access-scope-set/get 无法正确管理创意模式应用
的可见范围。待文档协作支持妙搭能力后,再通过 lark-drive 域能力
引导修改。

TODO: 等文档协作支持妙搭能力后,在 skill 中加入使用文档域权限
能力修改创意模式可见范围的引导。

* docs(lark-apps): 在平台资源与应用源码边界添加路径规则,引导 agent 使用相对路径

`apps` 命令的 `--path`、`--file`、`--output` 只接受 cwd 下的相对路径,传绝对路径会报错。

* docs(lark-apps): 新增创意模式评论路由和裸 meta_token 识别引导

- 意图路由表新增创意模式应用评论,引导走 lark-drive 文档评论体系
- app_id 获取章节补充裸 meta_token 识别:非链接非 app_ 开头时尝试用 +get 解析

* refactor(apps): flatten creative-design built-in-skills into references

- Delete built-in-skills/ directory (9 nested sub-skill folders)
- Move media skill content to references/ as flat .md files
- Add assets/index.html React+Babel starter template
- Integrate publishing flow into creative-design SKILL.md
- Update harness reference docs (aily/claude/codex.md)
- Simplify lark-apps SKILL.md routing to point directly to creative-design
- Remove creative-design standalone .git directory

* refactor(apps): rename creative-design/SKILL.md to creative-design.md

Avoid being mistaken as an independent skill entry point.
Update all internal references (lark-apps routing table + 10 reference files).

* fix(apps): fail closed when queryAppType fails instead of falling back to full_stack

queryAppType now returns an error instead of silently returning "".
+init aborts if the app type cannot be determined, preventing wrong
scaffold type from being committed and pushed to the repository.

---------

Co-authored-by: zhangli <zhangli.268@bytedance.com>
2026-07-20 20:15:43 +08:00

2020 lines
71 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/internal/httpmock"
"github.com/larksuite/cli/internal/testutil/gitcmd"
"github.com/larksuite/cli/shortcuts/common"
)
// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command
// has a string flag "dir" (=dirFlag) registered, mirroring how +init reads it
// 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, "")
return common.TestNewRuntimeContext(cmd, nil)
}
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) {
result, err := parseCredentialInitEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git","commit_author_name":"Alice","commit_author_email":"alice@example.com"}}`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.RepositoryURL != "http://u:t@h/app_x.git" {
t.Errorf("RepositoryURL got %q", result.RepositoryURL)
}
if result.CommitAuthorName != "Alice" {
t.Errorf("CommitAuthorName got %q", result.CommitAuthorName)
}
if result.CommitAuthorEmail != "alice@example.com" {
t.Errorf("CommitAuthorEmail got %q", result.CommitAuthorEmail)
}
}
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 := parseCredentialInitEnvelope(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 stubAppType(reg *httpmock.Registry, appID, appType string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/" + appID,
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": appID,
"app_type": appType,
},
},
},
})
}
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", "", "")
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", "--app-type", "full_stack", "--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", "", "")
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_ModernHTML_SkipsSyncEvenWithoutSteering(t *testing.T) {
dir := t.TempDir() // no steering dir → sync would run for non-modern_html
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", "modern_html", ""); err != nil {
t.Fatal(err)
}
if findCallArg(f.calls, "npx", "skills", "sync") != nil {
t.Error("skills sync must be skipped for modern_html regardless of steering dir")
}
}
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", "", ""); 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", "", ""); 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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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")
}
// appType is empty, so scaffoldInitArgs falls back to "full_stack"
// and `app init` must still receive --app-type full_stack.
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", "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("app init missing expected --app-type 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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, *httpmock.Registry) {
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, reg := cmdutil.TestFactory(t, cfg)
return factory, stdout, stderr, reg
}
func TestAppsInit_Req1_Wording(t *testing.T) {
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)
}
data, err := decodeDryRunDataMap(stdout.Bytes())
if 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 sync --local") {
t.Errorf("dry-run scaffold string must NOT show --local on 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, reg2 := newAppsExecuteFactoryWithStderr(t)
stubAppType(reg2, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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 := gitcmd.Command(dir, args...)
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")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
// 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")
}
gitcmd.SetSynchronousMaintenanceEnv(t)
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", "", ""); 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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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, reg := newAppsExecuteFactory(t)
stubAppType(reg, "app_x", "FULL_STACK")
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)
}
m, err := decodeDryRunDataMap(stdout.Bytes())
if 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", "", "")
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)
}
}
func TestRunScaffold_HtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "html") {
t.Errorf("expected --app-type html in args: %v", c)
}
}
func TestRunScaffold_ModernHtmlPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "modern_html", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html in args: %v", c)
}
}
func TestRunScaffold_EmptyAppTypeFallback(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestRunScaffold_FullStackPassesTemplate(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ""}}}
withFakeRunner(t, f)
kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "full_stack", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if kind != scaffoldKindInit {
t.Errorf("kind = %q, want %q", kind, scaffoldKindInit)
}
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "full_stack") {
t.Errorf("expected --app-type full_stack in args: %v", c)
}
}
func TestScaffoldInitArgs_WithAppType(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x") {
t.Errorf("expected --app-type modern_html --app-id app_x, got %v", args)
}
// modern_html skips dependency install.
if !containsAll(args, "--skip-install") {
t.Errorf("expected --skip-install for modern_html, got %v", args)
}
for _, a := range args {
if a == "--source-path" {
t.Errorf("--source-path must not appear when sourcePath is empty: %v", args)
}
}
}
func TestPolicyForAppType(t *testing.T) {
// modern_html and html decouple all control points: skip install, env-pull, skills sync, app sync.
for _, at := range []string{"modern_html", "html"} {
if p := policyForAppType(at); !p.skipInstall || !p.skipEnvPull || !p.skipSkillsSync || !p.skipAppSync {
t.Errorf("%s policy = %+v, want all skip flags set", at, p)
}
}
// Unlisted types (including "") get the zero-value policy: everything runs.
for _, at := range []string{"full_stack", "", "backend"} {
if p := policyForAppType(at); p.skipInstall || p.skipEnvPull || p.skipSkillsSync || p.skipAppSync {
t.Errorf("policy for %q = %+v, want zero value", at, p)
}
}
}
func TestScaffoldInitArgs_SkipInstallOnlyForModernHTML(t *testing.T) {
// Non-modern_html types run the install step (no --skip-install).
for _, at := range []string{"full_stack", "", "backend"} {
args := scaffoldInitArgs(at, "app_x", "")
for _, a := range args {
if a == "--skip-install" {
t.Errorf("--skip-install must not appear for app-type %q: %v", at, args)
}
}
}
}
func TestScaffoldInitArgs_EmptyFallback(t *testing.T) {
args := scaffoldInitArgs("", "app_x", "")
if !containsAll(args, "--app-type", "full_stack", "--app-id", "app_x") {
t.Errorf("expected --app-type full_stack fallback, got %v", args)
}
}
func TestScaffoldInitArgs_WithSourcePath(t *testing.T) {
args := scaffoldInitArgs("modern_html", "app_x", "/path/to/src")
if !containsAll(args, "--app-type", "modern_html", "--app-id", "app_x", "--source-path", "/path/to/src") {
t.Errorf("expected --source-path /path/to/src, got %v", args)
}
}
// configSetValue finds a `git config <key> <value>` SET call (not a `--get`)
// in the recorded fake calls and returns its value.
func configSetValue(calls [][]string, key string) (string, bool) {
for _, c := range calls {
if len(c) >= 5 && c[1] == "git" && c[2] == "config" && c[3] == key {
return c[4], true
}
}
return "", false
}
func TestEnsureGitIdentity_SetsDefaultsWhenUnset(t *testing.T) {
f := &fakeCommandRunner{} // no "git config" result → `--get` returns empty stdout
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v, ok := configSetValue(f.calls, "user.name"); !ok || v != defaultGitUserName {
t.Errorf("user.name set = (%q,%v), want %q", v, ok, defaultGitUserName)
}
if v, ok := configSetValue(f.calls, "user.email"); !ok || v != defaultGitUserEmail {
t.Errorf("user.email set = (%q,%v), want %q", v, ok, defaultGitUserEmail)
}
}
func TestEnsureGitIdentity_RespectsExisting(t *testing.T) {
// `git config --get` returns a value → identity resolvable, nothing is set.
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stdout: "Existing Dev\n"},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := configSetValue(f.calls, "user.name"); ok {
t.Error("user.name must not be overwritten when already configured")
}
if _, ok := configSetValue(f.calls, "user.email"); ok {
t.Error("user.email must not be overwritten when already configured")
}
}
func TestEnsureGitIdentity_SetFailurePropagates(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"git config": {stderr: "boom", err: errors.New("exit 1")},
}}
withFakeRunner(t, f)
if err := ensureGitIdentity(context.Background(), "/repo", "", ""); err == nil {
t.Error("expected error when git config set fails")
}
}
func TestAppsInit_WithAppType_FreshClone(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("http://u:t@h/app_typed.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"git status": {stdout: " A src/app.ts\n"},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
// Register a meta mock so queryAppType returns "modern_html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["app_type"] != "modern_html" {
t.Errorf("app_type = %v, want modern_html", data["app_type"])
}
// Verify the scaffold used --app-type modern_html
c := findCall(f.calls, "npx", "-y")
if c == nil {
t.Fatal("npx not called")
}
if !containsAll(c, "--app-type", "modern_html") {
t.Errorf("expected --app-type modern_html, got %v", c)
}
}
func TestAppsInit_ModernHtml_SkipsEnvPull(t *testing.T) {
f := &fakeCommandRunner{results: map[string]fakeCallResult{
"credential-init": credInitOK("https://git.test/app_mh.git"),
"git clone": {},
"git checkout": {},
"git ls-files": {stdout: ""},
"npx -y": {},
"git status": {stdout: ""},
}}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh",
"app_type": "MODERN_HTML",
},
},
},
})
dir := relCloneDir(t)
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if data["env_pulled"] != false {
t.Errorf("env_pulled = %v, want false", data["env_pulled"])
}
// Verify env-pull was NOT called
for _, c := range f.calls {
if len(c) >= 3 && c[2] == "apps" && len(c) >= 4 && c[3] == "+env-pull" {
t.Fatal("env-pull should not be called for modern_html")
}
}
}
func TestAppsInit_AlreadyInitialized_ModernHtml_SkipsEnvPull(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_mh2"}`), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeCommandRunner{}
withFakeRunner(t, f)
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_mh2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_mh2",
"app_type": "MODERN_HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_mh2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["env_pull_skipped"] != true {
t.Errorf("env_pull_skipped = %v, want true", data["env_pull_skipped"])
}
if len(f.calls) != 0 {
t.Errorf("no commands should be called for already-initialized modern_html, got %v", f.calls)
}
}
func TestAppsInit_WithAppType_AlreadyInitialized(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_typed2"}`), 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, reg := newAppsExecuteFactory(t)
// Register meta mock so queryAppType returns "html"
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/spark/v1/apps/app_typed2",
Body: map[string]interface{}{
"code": float64(0),
"data": map[string]interface{}{
"app": map[string]interface{}{
"app_id": "app_typed2",
"app_type": "HTML",
},
},
},
})
if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_typed2", "--dir", dir, "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := parseEnvelopeData(t, stdout)
if data["scaffold"] != "already_initialized" {
t.Errorf("scaffold = %v, want already_initialized", data["scaffold"])
}
if data["app_type"] != "html" {
t.Errorf("app_type = %v, want html", data["app_type"])
}
}