mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
3c2cc273f7
* 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>
2020 lines
71 KiB
Go
2020 lines
71 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/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"])
|
||
}
|
||
}
|