feat: inspect svglide run status

This commit is contained in:
songtianyi.theo
2026-07-02 22:34:19 +08:00
parent e196f68ef6
commit 8a450b6437
4 changed files with 885 additions and 4 deletions

View File

@@ -109,12 +109,12 @@ func NewRun(cfg NewRunConfig) Run {
func DefaultStages() []Stage {
return []Stage{
{Name: StageRequest, Status: StatusPending, Inputs: []string{}, Outputs: []string{"request/request.json", "request/source_manifest.json"}, Receipt: "receipts/request.json"},
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
{Name: StageResearch, Status: StatusPending, Inputs: []string{"request/request.json", "request/source_manifest.json"}, Outputs: []string{"research/research_notes.md", "research/sources.json"}, Receipt: "receipts/research.json"},
{Name: StageDesignBrief, Status: StatusPending, Inputs: []string{"request/request.json", "research/research_notes.md"}, Outputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Receipt: "receipts/design_brief.json"},
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
{Name: StageOutline, Status: StatusPending, Inputs: []string{"brief/design_brief.json", "brief/visual_system.json"}, Outputs: []string{"outline/deck.json"}, Receipt: "receipts/outline.json"},
{Name: StageSlideContent, Status: StatusPending, Inputs: []string{"outline/deck.json", "research/research_notes.md"}, Outputs: []string{"content/slide_content.md", "content/slide_content.json"}, Receipt: "receipts/slide_content.json"},
{Name: StageAssets, Status: StatusPending, Inputs: []string{"content/slide_content.json", "brief/visual_system.json"}, Outputs: []string{"assets/assets_plan.json"}, Receipt: "receipts/assets.json"},
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides"}, Receipt: "receipts/svg_author.json"},
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
{Name: StageSVGAuthor, Status: StatusPending, Inputs: []string{"outline/deck.json", "content/slide_content.json", "brief/visual_system.json", "assets/assets_plan.json"}, Outputs: []string{"slides/*.svg"}, Receipt: "receipts/svg_author.json"},
{Name: StageValidatePreviewRepair, Status: StatusPending, Inputs: []string{"slides/*.svg"}, Outputs: []string{"receipts/lint.json", "receipts/preview.json", "repair_queue.md", "preview.html"}, Receipt: "receipts/validate_preview_repair.json"},
}
}

View File

@@ -40,6 +40,36 @@ func TestDefaultStagesAreOrdered(t *testing.T) {
}
}
func TestDefaultStagesRequireGeneratedSlideSVGs(t *testing.T) {
stages := DefaultStages()
svgAuthor := mustStage(t, stages, StageSVGAuthor)
if !reflect.DeepEqual(svgAuthor.Outputs, []string{"slides/*.svg"}) {
t.Fatalf("svg_author Outputs = %v, want slides/*.svg", svgAuthor.Outputs)
}
repair := mustStage(t, stages, StageValidatePreviewRepair)
if !reflect.DeepEqual(repair.Inputs, []string{"slides/*.svg"}) {
t.Fatalf("validate_preview_repair Inputs = %v, want slides/*.svg", repair.Inputs)
}
}
func TestDefaultStagesResearchInputsMatchPromptContract(t *testing.T) {
stages := DefaultStages()
research := mustStage(t, stages, StageResearch)
want := []string{"request/request.json", "request/source_manifest.json"}
if !reflect.DeepEqual(research.Inputs, want) {
t.Fatalf("research Inputs = %v, want %v", research.Inputs, want)
}
}
func TestDefaultStagesOutlineInputsMatchPromptContract(t *testing.T) {
stages := DefaultStages()
outline := mustStage(t, stages, StageOutline)
want := []string{"brief/design_brief.json", "brief/visual_system.json"}
if !reflect.DeepEqual(outline.Inputs, want) {
t.Fatalf("outline Inputs = %v, want %v", outline.Inputs, want)
}
}
func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
now := time.Date(2026, 7, 2, 15, 4, 5, 0, time.UTC)
run := NewRun(NewRunConfig{
@@ -111,3 +141,14 @@ func TestNewRunDefaultsToCodexRuntime(t *testing.T) {
t.Fatalf("Policy = %+v, want %+v", run.Policy, wantPolicy)
}
}
func mustStage(t *testing.T, stages []Stage, name string) Stage {
t.Helper()
for _, stage := range stages {
if stage.Name == name {
return stage
}
}
t.Fatalf("missing stage %q", name)
return Stage{}
}

391
internal/svglide/status.go Normal file
View File

@@ -0,0 +1,391 @@
package svglide
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
type StatusReport struct {
CurrentStage string `json:"current_stage"`
MissingInputs []string `json:"missing_inputs"`
MissingOutputs []string `json:"missing_outputs"`
NextCommand string `json:"next_command"`
}
type NextTaskReport struct {
Stage string `json:"stage"`
PromptPath string `json:"prompt_path"`
Inputs []string `json:"inputs"`
Outputs []string `json:"outputs"`
}
func ReadRun(root string) (Run, error) {
safeRoot, err := validate.SafeInputPath(root)
if err != nil {
return Run{}, err
}
return readRunFile(safeRoot)
}
func InspectStatus(root string) (StatusReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return StatusReport{}, err
}
stage, err := currentStage(run)
if err != nil {
return StatusReport{}, err
}
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
if err != nil {
return StatusReport{}, err
}
missingOutputs, err := missingRunPaths(safeRoot, stage.Outputs)
if err != nil {
return StatusReport{}, err
}
return StatusReport{
CurrentStage: stage.Name,
MissingInputs: missingInputs,
MissingOutputs: missingOutputs,
NextCommand: fmt.Sprintf("lark-cli slides +create-svglide --action next --run %s", shellQuote(root)),
}, nil
}
func NextTask(root string) (NextTaskReport, error) {
safeRoot, run, err := readRun(root)
if err != nil {
return NextTaskReport{}, err
}
stage, err := currentStage(run)
if err != nil {
return NextTaskReport{}, err
}
missingInputs, err := missingRunPaths(safeRoot, stage.Inputs)
if err != nil {
return NextTaskReport{}, err
}
if len(missingInputs) > 0 {
return NextTaskReport{}, fmt.Errorf("current stage %q missing inputs: %s", stage.Name, strings.Join(missingInputs, ", "))
}
promptPath, err := promptPathForStage(stage.Name)
if err != nil {
return NextTaskReport{}, err
}
inputs, err := validateRunPaths(safeRoot, stage.Inputs)
if err != nil {
return NextTaskReport{}, err
}
outputs, err := validateRunPaths(safeRoot, stage.Outputs)
if err != nil {
return NextTaskReport{}, err
}
return NextTaskReport{
Stage: stage.Name,
PromptPath: promptPath,
Inputs: inputs,
Outputs: outputs,
}, nil
}
func readRun(root string) (string, Run, error) {
safeRoot, err := validate.SafeInputPath(root)
if err != nil {
return "", Run{}, err
}
run, err := readRunFile(safeRoot)
if err != nil {
return "", Run{}, err
}
return safeRoot, run, nil
}
func readRunFile(safeRoot string) (Run, error) {
raw, err := vfs.ReadFile(filepath.Join(safeRoot, "run.json"))
if err != nil {
return Run{}, err
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
return Run{}, fmt.Errorf("read run.json: %w", err)
}
return run, nil
}
func currentStage(run Run) (Stage, error) {
for _, stage := range run.Stages {
if stage.Name == run.CurrentStage {
return stage, nil
}
}
return Stage{}, fmt.Errorf("current stage %q not found in run", run.CurrentStage)
}
func missingRunPaths(safeRoot string, rels []string) ([]string, error) {
var missing []string
for _, rel := range rels {
if hasGlobMeta(rel) {
exists, err := runGlobExists(safeRoot, rel)
if err != nil {
return nil, err
}
if !exists {
missing = append(missing, rel)
}
continue
}
exists, err := runRegularFileExists(safeRoot, rel)
if err != nil {
return nil, fmt.Errorf("lstat run path %q: %w", rel, err)
}
if !exists {
missing = append(missing, rel)
}
}
return missing, nil
}
func validateRunPaths(safeRoot string, rels []string) ([]string, error) {
paths := make([]string, 0, len(rels))
for _, rel := range rels {
if hasGlobMeta(rel) {
if _, _, _, err := validateRunGlobPattern(safeRoot, rel); err != nil {
return nil, err
}
} else {
if _, err := safeRunPath(safeRoot, rel); err != nil {
return nil, err
}
}
paths = append(paths, rel)
}
return paths, nil
}
func runGlobExists(safeRoot, rel string) (bool, error) {
dirRel, pattern, dirPath, err := validateRunGlobPattern(safeRoot, rel)
if err != nil {
return false, err
}
dirPath, exists, err := runDirectoryExists(safeRoot, dirRel)
if err != nil {
return false, fmt.Errorf("lstat glob directory for %q: %w", rel, err)
}
if !exists {
return false, nil
}
entries, err := vfs.ReadDir(dirPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("read glob directory for %q: %w", rel, err)
}
for _, entry := range entries {
matched, err := filepath.Match(pattern, entry.Name())
if err != nil {
return false, fmt.Errorf("invalid glob pattern %q: %w", rel, err)
}
if !matched {
continue
}
matchRel := filepath.Join(dirRel, entry.Name())
exists, err := runRegularFileExists(safeRoot, matchRel)
if err != nil {
return false, fmt.Errorf("lstat glob match %q: %w", matchRel, err)
}
if exists {
return true, nil
}
}
return false, nil
}
func validateRunGlobPattern(safeRoot, rel string) (string, string, string, error) {
if strings.TrimSpace(rel) == "" {
return "", "", "", fmt.Errorf("run path must not be empty")
}
if isAbsoluteRunPath(rel) {
return "", "", "", fmt.Errorf("run path %q must be relative to run root", rel)
}
cleanRel := filepath.Clean(rel)
dirRel, pattern := filepath.Split(cleanRel)
dirRel = strings.TrimSuffix(dirRel, string(filepath.Separator))
if pattern == "" {
return "", "", "", fmt.Errorf("glob path %q is missing a file pattern", rel)
}
if _, err := filepath.Match(pattern, ""); err != nil {
return "", "", "", fmt.Errorf("invalid glob pattern %q: %w", rel, err)
}
if dirRel == "" {
dirRel = "."
}
if hasGlobMeta(dirRel) {
return "", "", "", fmt.Errorf("glob path %q is only supported in the file name", rel)
}
dirPath, err := safeRunPath(safeRoot, dirRel)
if err != nil {
return "", "", "", err
}
return dirRel, pattern, dirPath, nil
}
func runDirectoryExists(safeRoot, rel string) (string, bool, error) {
info, path, exists, err := lstatRunPath(safeRoot, rel)
if err != nil {
return path, false, err
}
if !exists {
return path, false, nil
}
if !info.IsDir() {
return path, false, fmt.Errorf("run path %q is not a directory", rel)
}
return path, true, nil
}
func runRegularFileExists(safeRoot, rel string) (bool, error) {
info, _, exists, err := lstatRunPath(safeRoot, rel)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
return info.Mode().IsRegular(), nil
}
func lstatRunPath(safeRoot, rel string) (fs.FileInfo, string, bool, error) {
path, err := safeRunPath(safeRoot, rel)
if err != nil {
return nil, "", false, err
}
cleanRel := filepath.Clean(rel)
if cleanRel == "." {
info, err := vfs.Lstat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, path, false, nil
}
return nil, path, false, err
}
if info.Mode()&fs.ModeSymlink != 0 {
return nil, path, false, nil
}
return info, path, true, nil
}
parts := strings.Split(cleanRel, string(filepath.Separator))
cur := safeRoot
var info fs.FileInfo
for i, part := range parts {
if part == "" || part == "." {
continue
}
cur = filepath.Join(cur, part)
info, err = vfs.Lstat(cur)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, path, false, nil
}
return nil, path, false, err
}
if info.Mode()&fs.ModeSymlink != 0 {
return nil, path, false, nil
}
if i < len(parts)-1 && !info.IsDir() {
return nil, path, false, fmt.Errorf("run path component %q is not a directory", filepath.Join(parts[:i+1]...))
}
}
if info == nil {
return nil, path, false, nil
}
return info, path, true, nil
}
func hasGlobMeta(path string) bool {
return strings.ContainsAny(path, "*?[")
}
func safeRunPath(safeRoot, rel string) (string, error) {
if strings.TrimSpace(rel) == "" {
return "", fmt.Errorf("run path must not be empty")
}
if isAbsoluteRunPath(rel) {
return "", fmt.Errorf("run path %q must be relative to run root", rel)
}
cleanRel := filepath.Clean(rel)
path := filepath.Clean(filepath.Join(safeRoot, cleanRel))
rootRel, err := filepath.Rel(safeRoot, path)
if err != nil {
return "", fmt.Errorf("cannot compare run path %q with run root: %w", rel, err)
}
if rootRel == ".." || strings.HasPrefix(rootRel, ".."+string(filepath.Separator)) || filepath.IsAbs(rootRel) {
return "", fmt.Errorf("run path %q escapes run root", rel)
}
return path, nil
}
func isAbsoluteRunPath(path string) bool {
path = strings.TrimSpace(path)
if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) {
return true
}
if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') {
drive := path[0]
return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z')
}
return false
}
func shellQuote(value string) string {
if value == "" {
return "''"
}
if isShellBareword(value) {
return value
}
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
func isShellBareword(value string) bool {
for _, r := range value {
if ('a' <= r && r <= 'z') || ('A' <= r && r <= 'Z') || ('0' <= r && r <= '9') {
continue
}
if strings.ContainsRune("_@%+=:,./-", r) {
continue
}
return false
}
return true
}
func promptPathForStage(stage string) (string, error) {
switch stage {
case StageRequest:
return "prompts/01_request.task.md", nil
case StageResearch:
return "prompts/02_research.task.md", nil
case StageDesignBrief:
return "prompts/03_design_brief.task.md", nil
case StageOutline:
return "prompts/04_outline.task.md", nil
case StageSlideContent:
return "prompts/05_slide_content.task.md", nil
case StageAssets:
return "prompts/06_assets.task.md", nil
case StageSVGAuthor:
return "prompts/07_svg_author.task.md", nil
case StageValidatePreviewRepair:
return "prompts/08_repair.task.md", nil
default:
return "", fmt.Errorf("stage %q has no prompt mapping", stage)
}
}

View File

@@ -0,0 +1,449 @@
package svglide
import (
"encoding/json"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestStatusReportsMissingOutputs(t *testing.T) {
initStatusTestRun(t)
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if status.CurrentStage != StageRequest {
t.Fatalf("CurrentStage = %q, want %q", status.CurrentStage, StageRequest)
}
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
t.Fatalf("MissingOutputs = %v, want request/source_manifest.json", status.MissingOutputs)
}
if len(status.MissingInputs) != 0 {
t.Fatalf("MissingInputs = %v, want empty", status.MissingInputs)
}
if status.NextCommand != "lark-cli slides +create-svglide --action next --run demo" {
t.Fatalf("NextCommand = %q, want --action next shortcut with caller root", status.NextCommand)
}
}
func TestStatusQuotesNextCommandRunPath(t *testing.T) {
tests := []struct {
root string
want string
}{
{
root: "demo dir",
want: "lark-cli slides +create-svglide --action next --run 'demo dir'",
},
{
root: "demo' dir",
want: "lark-cli slides +create-svglide --action next --run 'demo'\\'' dir'",
},
{
root: "demo trail ",
want: "lark-cli slides +create-svglide --action next --run 'demo trail '",
},
}
for _, tt := range tests {
t.Run(tt.root, func(t *testing.T) {
cwd := initStatusTestRunAt(t, tt.root)
status, err := InspectStatus(tt.root)
if err != nil {
t.Fatal(err)
}
if status.NextCommand != tt.want {
t.Fatalf("NextCommand = %q, want %q", status.NextCommand, tt.want)
}
if strings.Contains(status.NextCommand, cwd) {
t.Fatalf("NextCommand = %q, should not contain absolute safe root %q", status.NextCommand, cwd)
}
})
}
}
func TestNextReturnsCurrentTaskPrompt(t *testing.T) {
initStatusTestRun(t)
next, err := NextTask("demo")
if err != nil {
t.Fatal(err)
}
if next.Stage != StageRequest {
t.Fatalf("Stage = %q, want %q", next.Stage, StageRequest)
}
if next.PromptPath != "prompts/01_request.task.md" {
t.Fatalf("PromptPath = %q, want prompts/01_request.task.md", next.PromptPath)
}
if filepath.IsAbs(next.PromptPath) {
t.Fatalf("PromptPath = %q, want relative path", next.PromptPath)
}
if _, err := os.Stat(filepath.Join("demo", next.PromptPath)); err != nil {
t.Fatalf("missing prompt %s: %v", next.PromptPath, err)
}
if len(next.Inputs) != 0 {
t.Fatalf("Inputs = %v, want empty", next.Inputs)
}
if !slices.Equal(next.Outputs, []string{"request/request.json", "request/source_manifest.json"}) {
t.Fatalf("Outputs = %v, want request outputs", next.Outputs)
}
}
func TestInspectStatusRejectsUnsafeRunPath(t *testing.T) {
t.Chdir(t.TempDir())
if _, err := InspectStatus("../escape"); err == nil {
t.Fatal("expected unsafe run path refusal")
}
}
func TestReadRunReadsRunJSONAndRejectsAbsoluteRunPath(t *testing.T) {
cwd := initStatusTestRun(t)
run, err := ReadRun("demo")
if err != nil {
t.Fatal(err)
}
if run.Title != "Demo" || run.CurrentStage != StageRequest {
t.Fatalf("unexpected run: %+v", run)
}
if _, err := ReadRun(filepath.Join(cwd, "demo")); err == nil {
t.Fatal("expected absolute run path refusal")
}
}
func TestInspectStatusRejectsEscapingStagePath(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
writeStatusTestRunFile(t, run)
if _, err := InspectStatus("demo"); err == nil {
t.Fatal("expected escaping stage output path refusal")
}
}
func TestInspectStatusReturnsStatErrorsThatAreNotMissing(t *testing.T) {
initStatusTestRun(t)
if err := os.RemoveAll(filepath.Join("demo", "request")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join("demo", "request"), []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := InspectStatus("demo"); err == nil {
t.Fatal("expected stat error when output parent is a file")
}
}
func TestInspectStatusReportsDirectoryArtifactAsMissing(t *testing.T) {
initStatusTestRun(t)
path := filepath.Join("demo", "request", "source_manifest.json")
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "request/source_manifest.json") {
t.Fatalf("MissingOutputs = %v, want directory artifact to be missing", status.MissingOutputs)
}
}
func TestNextTaskRejectsEscapingStagePath(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageRequest, []string{"../outside.json"})
writeStatusTestRunFile(t, run)
if _, err := NextTask("demo"); err == nil {
t.Fatal("expected escaping stage output path refusal")
}
}
func TestNextTaskRejectsMissingCurrentStageInputs(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
run.CurrentStage = StageDesignBrief
writeStatusTestRunFile(t, run)
if _, err := NextTask("demo"); err == nil {
t.Fatal("expected missing current stage inputs to reject next task")
}
}
func TestNextTaskRejectsResearchMissingSourceManifest(t *testing.T) {
initStatusTestRun(t)
if err := os.Remove(filepath.Join("demo", "request", "source_manifest.json")); err != nil {
t.Fatal(err)
}
run := readStatusTestRunFile(t)
run.CurrentStage = StageResearch
writeStatusTestRunFile(t, run)
if _, err := NextTask("demo"); err == nil {
t.Fatal("expected missing research source manifest to reject next task")
}
}
func TestNextTaskRejectsOutlineMissingVisualSystem(t *testing.T) {
initStatusTestRun(t)
if err := os.WriteFile(filepath.Join("demo", "brief", "design_brief.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
run := readStatusTestRunFile(t)
run.CurrentStage = StageOutline
writeStatusTestRunFile(t, run)
if _, err := NextTask("demo"); err == nil {
t.Fatal("expected missing outline visual system to reject next task")
}
}
func TestInspectStatusReportsMissingGlobUntilMatched(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
run.CurrentStage = StageSVGAuthor
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"slides/*.svg"})
writeStatusTestRunFile(t, run)
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
t.Fatalf("MissingOutputs = %v, want slides/*.svg", status.MissingOutputs)
}
if err := os.WriteFile(filepath.Join("demo", "slides", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
status, err = InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if slices.Contains(status.MissingOutputs, "slides/*.svg") {
t.Fatalf("MissingOutputs = %v, want glob satisfied by slides/01.svg", status.MissingOutputs)
}
}
func TestInspectStatusDoesNotSatisfyGlobThroughIntermediateSymlink(t *testing.T) {
cwd := initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageSVGAuthor)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageSVGAuthor, []string{"link/bar/*.svg"})
writeStatusTestRunFile(t, run)
outside := filepath.Join(filepath.Dir(cwd), "outside")
if err := os.MkdirAll(filepath.Join(outside, "bar"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "bar", "01.svg"), []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "link/bar/*.svg") {
t.Fatalf("MissingOutputs = %v, want intermediate symlink glob to leave link/bar/*.svg missing", status.MissingOutputs)
}
}
func TestInspectStatusDoesNotSatisfyArtifactThroughIntermediateSymlink(t *testing.T) {
cwd := initStatusTestRun(t)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageRequest, []string{"link/request.json"})
writeStatusTestRunFile(t, run)
outside := filepath.Join(filepath.Dir(cwd), "outside")
if err := os.MkdirAll(outside, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outside, "request.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "link")); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "link/request.json") {
t.Fatalf("MissingOutputs = %v, want intermediate symlink artifact to leave link/request.json missing", status.MissingOutputs)
}
}
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlinkDirectory(t *testing.T) {
cwd := initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageSVGAuthor)
if err := os.RemoveAll(filepath.Join("demo", "slides")); err != nil {
t.Fatal(err)
}
outsideSlides := filepath.Join(filepath.Dir(cwd), "outside-slides")
if err := os.MkdirAll(outsideSlides, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(outsideSlides, "01.svg"), []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideSlides, filepath.Join("demo", "slides")); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
t.Fatalf("MissingOutputs = %v, want symlink directory glob to leave slides/*.svg missing", status.MissingOutputs)
}
}
func TestInspectStatusDoesNotSatisfyGlobWithDirectory(t *testing.T) {
initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageSVGAuthor)
if err := os.Mkdir(filepath.Join("demo", "slides", "01.svg"), 0o755); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
t.Fatalf("MissingOutputs = %v, want directory match to leave slides/*.svg missing", status.MissingOutputs)
}
}
func TestInspectStatusDoesNotSatisfyGlobWithEscapingSymlink(t *testing.T) {
cwd := initStatusTestRun(t)
setCurrentStageForStatusTest(t, StageSVGAuthor)
outside := filepath.Join(filepath.Dir(cwd), "outside.svg")
if err := os.WriteFile(outside, []byte("<svg/>"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join("demo", "slides", "01.svg")); err != nil {
t.Fatal(err)
}
status, err := InspectStatus("demo")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(status.MissingOutputs, "slides/*.svg") {
t.Fatalf("MissingOutputs = %v, want symlink match to leave slides/*.svg missing", status.MissingOutputs)
}
}
func TestInspectStatusRejectsInvalidGlobPattern(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
writeStatusTestRunFile(t, run)
if _, err := InspectStatus("demo"); err == nil {
t.Fatal("expected invalid glob pattern error")
}
}
func TestNextTaskRejectsInvalidGlobPattern(t *testing.T) {
initStatusTestRun(t)
run := readStatusTestRunFile(t)
setStatusTestStageOutputs(t, &run, StageRequest, []string{"slides/[.svg"})
writeStatusTestRunFile(t, run)
if _, err := NextTask("demo"); err == nil {
t.Fatal("expected invalid glob pattern error")
}
}
func initStatusTestRun(t *testing.T) string {
return initStatusTestRunAt(t, "demo")
}
func initStatusTestRunAt(t *testing.T, root string) string {
t.Helper()
cwd := t.TempDir()
t.Chdir(cwd)
if err := os.WriteFile("source.md", []byte("# Demo"), 0o644); err != nil {
t.Fatal(err)
}
initRoot := root
if trimmed := strings.TrimSpace(root); trimmed != root {
initRoot = trimmed
}
if err := InitRun(initRoot, InitOptions{Title: "Demo", Input: "source.md"}); err != nil {
t.Fatal(err)
}
if initRoot != root {
if err := os.Rename(initRoot, root); err != nil {
t.Fatal(err)
}
}
return cwd
}
func readStatusTestRunFile(t *testing.T) Run {
t.Helper()
raw, err := os.ReadFile(filepath.Join("demo", "run.json"))
if err != nil {
t.Fatal(err)
}
var run Run
if err := json.Unmarshal(raw, &run); err != nil {
t.Fatal(err)
}
return run
}
func writeStatusTestRunFile(t *testing.T, run Run) {
t.Helper()
raw, err := json.MarshalIndent(run, "", " ")
if err != nil {
t.Fatal(err)
}
raw = append(raw, '\n')
if err := os.WriteFile(filepath.Join("demo", "run.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
}
func setStatusTestStageOutputs(t *testing.T, run *Run, stageName string, outputs []string) {
t.Helper()
for i := range run.Stages {
if run.Stages[i].Name == stageName {
run.Stages[i].Outputs = outputs
return
}
}
t.Fatalf("missing stage %q", stageName)
}
func setCurrentStageForStatusTest(t *testing.T, stageName string) {
t.Helper()
run := readStatusTestRunFile(t)
run.CurrentStage = stageName
writeStatusTestRunFile(t, run)
}