mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 15:31:05 +08:00
feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)
Replaces the Python agent canvas runtime with a Go implementation that runs inside `cmd/server_main`. The canvas compiles into an eino Workflow that pauses on wait-for-user via native Interrupt/Resume (no sentinel flag) and resumes from a Redis-backed CheckPointStore. All 21 Python agent components and ~35 tools are ported with functional parity. Sandbox providers now read their JSON config from the admin-panel system_settings table with env fallback. 234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate added); drops the v2 DSL E2E step and the gap-analysis plan (both redundant after the port ships). ## Type of change - [x] Refactoring - [x] New feature - [x] Bug fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
175
tools/gen-component-parity/main.go
Normal file
175
tools/gen-component-parity/main.go
Normal file
@@ -0,0 +1,175 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
|
||||
// gen-component-parity is a small developer tool that walks the live Go
|
||||
// component and tool registries and emits a markdown parity matrix for
|
||||
// docs/component-parity.md. It supersedes the manually-curated table
|
||||
// previously maintained in that file.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./tools/gen-component-parity > docs/component-parity.generated.md
|
||||
// go run ./tools/gen-component-parity -format=tsv
|
||||
//
|
||||
// The tool imports the production packages (which trigger init()
|
||||
// registration of every component / tool factory) and reflects on each
|
||||
// registered entry's Name() / Inputs() / Outputs() (for components) or
|
||||
// Info() (for tools). It then writes a markdown table to stdout.
|
||||
//
|
||||
// Limitations:
|
||||
//
|
||||
// - The script does NOT evaluate Python parity. Every entry is marked
|
||||
// "🟡 parity check via docs/component-parity.md" — the human-curated
|
||||
// table is the source of truth for parity; this script only emits
|
||||
// the structural inventory.
|
||||
// - Component factories that require non-empty params to construct
|
||||
// (e.g. Retrieval, ExeSQL) are skipped with a "⏭" annotation; their
|
||||
// parity is captured in the hand-written matrix.
|
||||
// - The script does not import cmd/server_main.go to avoid pulling
|
||||
// in the full boot path (Redis, MySQL, etc.).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"ragflow/internal/agent/component"
|
||||
"ragflow/internal/agent/tool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
format := flag.String("format", "md", "output format: md|tsv")
|
||||
flag.Parse()
|
||||
|
||||
switch *format {
|
||||
case "md":
|
||||
writeMarkdown()
|
||||
case "tsv":
|
||||
writeTSV()
|
||||
default:
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "unknown format %q (md|tsv)\n", *format)
|
||||
flag.CommandLine.Usage()
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdown() {
|
||||
fmt.Println("# Component & Tool Registry — Auto-generated inventory")
|
||||
fmt.Println()
|
||||
fmt.Println("> Generated by `tools/gen-component-parity`. Do not edit by hand;")
|
||||
fmt.Println("> the human-curated `docs/component-parity.md` is the source of truth for")
|
||||
fmt.Println("> Python parity annotations (✅/🟡/⚠️/❌). This file only emits the")
|
||||
fmt.Println("> structural inventory (registry name, factory, public surface).")
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("## Universe A — Canvas DAG components")
|
||||
fmt.Println()
|
||||
fmt.Println("| Name | Source file | Public surface |")
|
||||
fmt.Println("|---|---|---|")
|
||||
for _, name := range component.RegisteredNames() {
|
||||
c, err := component.New(name, map[string]any{})
|
||||
if err != nil {
|
||||
// Some components (ExeSQL, Retrieval, DocsGenerator, ListOperations, ...)
|
||||
// require non-empty params at construction time. We surface those as
|
||||
// "requires params" rather than "error" so the table reads as
|
||||
// "factory exists, params needed" — the human-curated parity doc
|
||||
// already documents the param surface.
|
||||
fmt.Printf("| %s | `internal/agent/component/*.go` | (requires non-empty params) |\n", name)
|
||||
continue
|
||||
}
|
||||
surface := summariseComponent(c)
|
||||
fmt.Printf("| %s | `internal/agent/component/*.go` | %s |\n", name, surface)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("## Universe B — eino ReAct tools")
|
||||
fmt.Println()
|
||||
fmt.Println("| Name | Public surface |")
|
||||
fmt.Println("|---|---|")
|
||||
for _, name := range sortedToolNames() {
|
||||
bt, err := tool.BuildByName(name, map[string]any{})
|
||||
if err != nil {
|
||||
fmt.Printf("| %s | (build error: %s) |\n", name, err.Error())
|
||||
continue
|
||||
}
|
||||
info, infoErr := bt.Info(context.Background())
|
||||
surface := "no Info()"
|
||||
if infoErr == nil && info != nil {
|
||||
surface = fmt.Sprintf("name=%q desc=%q", info.Name, truncate(info.Desc, 80))
|
||||
}
|
||||
fmt.Printf("| %s | %s |\n", name, surface)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTSV() {
|
||||
fmt.Println("universe\tname\tsurface")
|
||||
for _, name := range component.RegisteredNames() {
|
||||
c, err := component.New(name, map[string]any{})
|
||||
if err != nil {
|
||||
fmt.Printf("A\t%s\trequires_params\n", name)
|
||||
continue
|
||||
}
|
||||
fmt.Printf("A\t%s\t%s\n", name, summariseComponent(c))
|
||||
}
|
||||
for _, name := range sortedToolNames() {
|
||||
bt, err := tool.BuildByName(name, map[string]any{})
|
||||
if err != nil {
|
||||
fmt.Printf("B\t%s\tERROR:%s\n", name, err.Error())
|
||||
continue
|
||||
}
|
||||
info, infoErr := bt.Info(context.Background())
|
||||
surface := "no Info()"
|
||||
if infoErr == nil && info != nil {
|
||||
surface = info.Name
|
||||
}
|
||||
fmt.Printf("B\t%s\t%s\n", name, surface)
|
||||
}
|
||||
}
|
||||
|
||||
func summariseComponent(c component.Component) string {
|
||||
parts := []string{
|
||||
fmt.Sprintf("Name=%q", c.Name()),
|
||||
}
|
||||
in := c.Inputs()
|
||||
out := c.Outputs()
|
||||
if len(in) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("inputs=%d", len(in)))
|
||||
}
|
||||
if len(out) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("outputs=%d", len(out)))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// sortedToolNames mirrors the universe A's RegisteredNames for
|
||||
// deterministic output. The tool package doesn't expose a public
|
||||
// registry iteration helper; we use BuildByName's error to probe the
|
||||
// known list of factory entries. In practice this list is the
|
||||
// hand-maintained keys in tool/registry.go:registry.
|
||||
func sortedToolNames() []string {
|
||||
known := []string{
|
||||
"akshare", "arxiv", "code_exec", "crawler", "deepl", "duckduckgo",
|
||||
"email", "execute_sql", "exesql", "github", "google", "google_scholar",
|
||||
"jin10", "pubmed", "qweather", "retrieval", "search_my_dataset",
|
||||
"search_my_dateset", "searxng", "tavily", "tushare", "wencai",
|
||||
"wikipedia", "yahoo_finance",
|
||||
}
|
||||
sort.Strings(known)
|
||||
return known
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n-3] + "..."
|
||||
}
|
||||
114
tools/gen-component-parity/main_test.go
Normal file
114
tools/gen-component-parity/main_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWriteMarkdown_SpotCheck pins the markdown output contract: it
|
||||
// must enumerate both universes and surface at least one entry per
|
||||
// universe. The exhaustive list is owned by RegisteredNames() and
|
||||
// tool/registry.go — this test just guards against the structural
|
||||
// shape regressing (lost header, lost divider, etc.).
|
||||
func TestWriteMarkdown_SpotCheck(t *testing.T) {
|
||||
// Capture stdout.
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() {
|
||||
os.Stdout = old
|
||||
}()
|
||||
|
||||
writeMarkdown()
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
text := string(out)
|
||||
|
||||
wants := []string{
|
||||
"## Universe A — Canvas DAG components",
|
||||
"## Universe B — eino ReAct tools",
|
||||
"| llm |",
|
||||
"| message |",
|
||||
"| agent |",
|
||||
"akshare",
|
||||
"wikipedia",
|
||||
"search_my_dateset",
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("markdown output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteTSV_SpotCheck pins the TSV output contract (used in CI
|
||||
// parity checks via the diff toolchain). The format is:
|
||||
//
|
||||
// universe\tname\tsurface
|
||||
//
|
||||
// Every line must have exactly two tab separators; the header line
|
||||
// and data lines are both checked.
|
||||
func TestWriteTSV_SpotCheck(t *testing.T) {
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
defer func() {
|
||||
os.Stdout = old
|
||||
}()
|
||||
|
||||
writeTSV()
|
||||
w.Close()
|
||||
out, _ := io.ReadAll(r)
|
||||
text := string(out)
|
||||
|
||||
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
||||
if len(lines) < 5 {
|
||||
t.Fatalf("TSV output too short: %d lines", len(lines))
|
||||
}
|
||||
if lines[0] != "universe\tname\tsurface" {
|
||||
t.Errorf("TSV header wrong: %q", lines[0])
|
||||
}
|
||||
for i, line := range lines[1:] {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) != 3 {
|
||||
t.Errorf("TSV line %d has %d fields, want 3: %q", i+1, len(fields), line)
|
||||
}
|
||||
if fields[0] != "A" && fields[0] != "B" {
|
||||
t.Errorf("TSV line %d universe %q not in {A,B}: %q", i+1, fields[0], line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummariseComponent_NotEmpty guards the surface string format.
|
||||
func TestSummariseComponent_NotEmpty(t *testing.T) {
|
||||
// We can't easily call summariseComponent without a real
|
||||
// component instance, so this test simply asserts the
|
||||
// formatter helpers are exported and don't panic on
|
||||
// empty input. (summariseComponent takes a Component; the
|
||||
// type assertion in the formatter would crash on nil, so
|
||||
// we only test truncate here.)
|
||||
if got := truncate("hello", 10); got != "hello" {
|
||||
t.Errorf("truncate short: got %q", got)
|
||||
}
|
||||
if got := truncate("hello world", 5); got != "he..." {
|
||||
t.Errorf("truncate long: got %q want %q", got, "he...")
|
||||
}
|
||||
}
|
||||
185
tools/migrate-canvas/main.go
Normal file
185
tools/migrate-canvas/main.go
Normal file
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
|
||||
// migrate-canvas applies Go's dsl.NormalizeForCanvas to one or more
|
||||
// JSON files and emits the normalized form. It is the "Go-side" of
|
||||
// the parity corpus described in the agent-go-port-design doc §7.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// # Pretty-print the normalized form of a single DSL file:
|
||||
// go run ./tools/migrate-canvas docs/develop/sample.json
|
||||
//
|
||||
// # Diff against a "golden" normalized file (CI mode):
|
||||
// go run ./tools/migrate-canvas -golden=expected.json docs/develop/sample.json
|
||||
//
|
||||
// # Walk every testdata fixture and emit the normalized form:
|
||||
// go run ./tools/migrate-canvas -walk internal/agent/dsl/testdata
|
||||
//
|
||||
// Behaviour:
|
||||
// - In non-CI mode, the tool writes the normalized JSON to stdout
|
||||
// (pretty-printed) and prints a one-line summary to stderr.
|
||||
// - In CI mode (-golden=<path>), the tool compares the actual
|
||||
// normalized form to the golden file and exits non-zero on drift.
|
||||
// - The tool never panics on malformed input: NormalizeForCanvas
|
||||
// is best-effort and the tool reports the result as a warning
|
||||
// when the input is empty / unparseable.
|
||||
//
|
||||
// Limitations vs the original plan §7 migrate-canvas spec:
|
||||
// - This tool does NOT shell out to Python. The Python side's
|
||||
// normalize_chunker_dsl is for the chunker DSL, which is a
|
||||
// different domain (chunking pipeline, not agent canvas). The
|
||||
// Go-side NormalizeForCanvas covers the agent canvas normalize
|
||||
// path; the chunker DSL still goes through the Python path
|
||||
// (deferred per plan v3.3.1 user decision).
|
||||
// - The "fixture corpus" used in CI is the existing 7 files in
|
||||
// internal/agent/dsl/testdata/; the tool can be extended to
|
||||
// add a -generate-golden flag that writes the current output
|
||||
// as the new golden (the standard update-on-intent pattern).
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
dslpkg "ragflow/internal/agent/dsl"
|
||||
)
|
||||
|
||||
func main() {
|
||||
goldenFlag := flag.String("golden", "", "path to golden file for CI drift check (mutually exclusive with -write-golden)")
|
||||
writeGolden := flag.Bool("write-golden", false, "write the normalized output to <input>.golden (update pattern)")
|
||||
walkDir := flag.String("walk", "", "if set, treat positional args as a directory; every *.json file is normalised")
|
||||
flag.Parse()
|
||||
|
||||
if *goldenFlag != "" && *writeGolden {
|
||||
fmt.Fprintln(os.Stderr, "migrate-canvas: -golden and -write-golden are mutually exclusive")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if *walkDir != "" {
|
||||
// Walk directory mode: positional args ignored; walk <walkDir>/**/*.json.
|
||||
runWalk(*walkDir, *goldenFlag, *writeGolden)
|
||||
return
|
||||
}
|
||||
|
||||
args := flag.Args()
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "migrate-canvas: expected <file.json> (or -walk <dir>)")
|
||||
flag.CommandLine.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
exit := 0
|
||||
for _, path := range args {
|
||||
if err := runOne(path, *goldenFlag, *writeGolden); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", path, err)
|
||||
exit = 1
|
||||
}
|
||||
}
|
||||
os.Exit(exit)
|
||||
}
|
||||
|
||||
func runOne(path, goldenPath string, writeGolden bool) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
var dsl map[string]any
|
||||
if err := json.Unmarshal(raw, &dsl); err != nil {
|
||||
return fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
normalised := dslpkg.NormalizeForCanvas(dsl)
|
||||
|
||||
if writeGolden {
|
||||
goldenPath = path + ".golden"
|
||||
out, mErr := marshalPretty(normalised)
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("marshal: %w", mErr)
|
||||
}
|
||||
if err := os.WriteFile(goldenPath, out, 0o644); err != nil {
|
||||
return fmt.Errorf("write golden: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "OK %s -> %s\n", path, goldenPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if goldenPath != "" {
|
||||
golden, gErr := os.ReadFile(goldenPath)
|
||||
if gErr != nil {
|
||||
return fmt.Errorf("read golden: %w", gErr)
|
||||
}
|
||||
actual, mErr := marshalPretty(normalised)
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("marshal: %w", mErr)
|
||||
}
|
||||
if !bytes.Equal(bytes.TrimSpace(golden), bytes.TrimSpace(actual)) {
|
||||
return fmt.Errorf("drift: golden != actual (run with -write-golden to update)")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "OK %s (matches %s)\n", path, goldenPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
out, mErr := marshalPretty(normalised)
|
||||
if mErr != nil {
|
||||
return fmt.Errorf("marshal: %w", mErr)
|
||||
}
|
||||
fmt.Print(string(out))
|
||||
fmt.Fprintf(os.Stderr, "OK %s (%d bytes)\n", path, len(out))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWalk(dir, goldenDir string, writeGolden bool) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate-canvas: walk: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
var files []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
files = append(files, filepath.Join(dir, name))
|
||||
}
|
||||
sort.Strings(files)
|
||||
exit := 0
|
||||
for _, p := range files {
|
||||
var golden string
|
||||
if goldenDir != "" {
|
||||
golden = filepath.Join(goldenDir, filepath.Base(p)+".golden")
|
||||
}
|
||||
if err := runOne(p, golden, writeGolden); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "FAIL %s: %v\n", p, err)
|
||||
exit = 1
|
||||
}
|
||||
}
|
||||
os.Exit(exit)
|
||||
}
|
||||
|
||||
func marshalPretty(v any) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
150
tools/migrate-canvas/main_test.go
Normal file
150
tools/migrate-canvas/main_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleDSL = `{
|
||||
"components": {
|
||||
"Begin:abc": {
|
||||
"downstream": ["Message:def"],
|
||||
"upstream": [],
|
||||
"obj": {"component_name": "Begin", "params": {}}
|
||||
},
|
||||
"Message:def": {
|
||||
"downstream": [],
|
||||
"upstream": ["Begin:abc"],
|
||||
"obj": {"component_name": "Message", "params": {"content": "hello"}}
|
||||
}
|
||||
},
|
||||
"globals": {"sys.query": "world"},
|
||||
"path": ["Begin:abc"]
|
||||
}`
|
||||
|
||||
func writeTempFile(t *testing.T, name, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func runMainCaptureStderr(t *testing.T, args []string) (stdout, stderr string, exitOK bool) {
|
||||
t.Helper()
|
||||
oldStdout, oldStderr := os.Stdout, os.Stderr
|
||||
rOut, wOut, _ := os.Pipe()
|
||||
rErr, wErr, _ := os.Pipe()
|
||||
os.Stdout, os.Stderr = wOut, wErr
|
||||
defer func() { os.Stdout, os.Stderr = oldStdout, oldStderr }()
|
||||
|
||||
// Re-parse flags from args.
|
||||
origArgs := os.Args
|
||||
os.Args = append([]string{"migrate-canvas"}, args...)
|
||||
defer func() { os.Args = origArgs }()
|
||||
|
||||
done := make(chan struct{})
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
go func() { _, _ = io.Copy(&outBuf, rOut) }()
|
||||
go func() { _, _ = io.Copy(&errBuf, rErr) }()
|
||||
|
||||
main()
|
||||
wOut.Close()
|
||||
wErr.Close()
|
||||
<-done
|
||||
out, _ := io.ReadAll(rOut)
|
||||
er, _ := io.ReadAll(rErr)
|
||||
_ = out
|
||||
_ = er
|
||||
return outBuf.String(), errBuf.String(), true
|
||||
}
|
||||
|
||||
// TestRunOne_PrettyPrintNoDrift exercises the happy path: a valid
|
||||
// DSL is normalised, the JSON is well-formed, and the output
|
||||
// contains the same top-level components we put in.
|
||||
func TestRunOne_PrettyPrintNoDrift(t *testing.T) {
|
||||
path := writeTempFile(t, "ok.json", sampleDSL)
|
||||
|
||||
rOut, wOut, _ := os.Pipe()
|
||||
oldStdout := os.Stdout
|
||||
os.Stdout = wOut
|
||||
defer func() { os.Stdout = oldStdout }()
|
||||
|
||||
if err := runOne(path, "", false); err != nil {
|
||||
t.Fatalf("runOne ok: %v", err)
|
||||
}
|
||||
wOut.Close()
|
||||
got, _ := io.ReadAll(rOut)
|
||||
|
||||
// Output must be valid JSON.
|
||||
var v map[string]any
|
||||
if err := json.Unmarshal(got, &v); err != nil {
|
||||
t.Fatalf("output not valid JSON: %v\nbody: %s", err, got)
|
||||
}
|
||||
if _, ok := v["components"]; !ok {
|
||||
t.Errorf("normalised output missing 'components' top-level key: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOne_GoldenMatch writes a golden, then re-reads the same
|
||||
// DSL and asserts the normalisation is stable (idempotent).
|
||||
func TestRunOne_GoldenMatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "x.json")
|
||||
if err := os.WriteFile(path, []byte(sampleDSL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Phase 1: write the golden.
|
||||
if err := runOne(path, "", true); err != nil {
|
||||
t.Fatalf("write-golden: %v", err)
|
||||
}
|
||||
golden := path + ".golden"
|
||||
if _, err := os.Stat(golden); err != nil {
|
||||
t.Fatalf("golden not written: %v", err)
|
||||
}
|
||||
|
||||
// Phase 2: re-read the same DSL, expect a match.
|
||||
if err := runOne(path, golden, false); err != nil {
|
||||
t.Errorf("golden match: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunOne_GoldenDrift covers the "drift detected" path: after
|
||||
// NormalizeForCanvas changes (hypothetically), a stale golden
|
||||
// surfaces as an error.
|
||||
func TestRunOne_GoldenDrift(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "x.json")
|
||||
if err := os.WriteFile(path, []byte(sampleDSL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Stale golden that doesn't match the normalised output.
|
||||
golden := filepath.Join(dir, "stale.json")
|
||||
if err := os.WriteFile(golden, []byte(`{"totally": "different"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := runOne(path, golden, false); err == nil {
|
||||
t.Errorf("expected drift error, got nil")
|
||||
} else if !strings.Contains(err.Error(), "drift") {
|
||||
t.Errorf("expected drift error, got: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user