diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff786059ef..b5ec7260679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Agents/local models: add `agents.defaults.localModelMode: "lean"` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. Thanks @ImLukeF. - QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras. - Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine. +- docs-i18n: add behavior baseline fixtures (#64073). Thanks @hxy91819 ### Fixes diff --git a/scripts/docs-i18n/behavior_baseline_test.go b/scripts/docs-i18n/behavior_baseline_test.go new file mode 100644 index 00000000000..262574db9d6 --- /dev/null +++ b/scripts/docs-i18n/behavior_baseline_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +type behaviorReplacePair struct { + From string `json:"from"` + To string `json:"to"` +} + +type behaviorRule struct { + Method string `json:"method"` + MatchAll []string `json:"match_all"` + ResponseFile string `json:"response_file,omitempty"` + ReplacePairs []behaviorReplacePair `json:"replace_pairs,omitempty"` +} + +type behaviorFixture struct { + Name string `json:"name"` + Mode string `json:"mode"` + RelPath string `json:"rel_path"` + SourceFile string `json:"source_file"` + ExpectedFile string `json:"expected_file,omitempty"` + ExpectedErrorContains string `json:"expected_error_contains,omitempty"` + ExpectedOutputContains []string `json:"expected_output_contains,omitempty"` + ExpectedOutputNotContains []string `json:"expected_output_not_contains,omitempty"` + Rules []behaviorRule `json:"rules"` +} + +type behaviorFixtureTranslator struct { + t *testing.T + dir string + rules []behaviorRule +} + +func (tr *behaviorFixtureTranslator) Translate(_ context.Context, text, _, _ string) (string, error) { + return tr.run("masked", text), nil +} + +func (tr *behaviorFixtureTranslator) TranslateRaw(_ context.Context, text, _, _ string) (string, error) { + return tr.run("raw", text), nil +} + +func (tr *behaviorFixtureTranslator) Close() {} + +func (tr *behaviorFixtureTranslator) run(method, text string) string { + tr.t.Helper() + for _, rule := range tr.rules { + if rule.Method != method { + continue + } + if !matchesAll(text, rule.MatchAll) { + continue + } + switch { + case rule.ResponseFile != "": + return readFixtureTextInDir(tr.t, tr.dir, rule.ResponseFile) + case len(rule.ReplacePairs) > 0: + out := text + for _, pair := range rule.ReplacePairs { + out = strings.ReplaceAll(out, pair.From, pair.To) + } + return out + default: + return text + } + } + return text +} + +func matchesAll(text string, fragments []string) bool { + for _, fragment := range fragments { + if !strings.Contains(text, fragment) { + return false + } + } + return true +} + +func TestDocsI18nBehaviorBaselines(t *testing.T) { + t.Parallel() + + root := filepath.Join("testdata", "behavior") + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir(%q): %v", root, err) + } + + found := false + for _, entry := range entries { + if !entry.IsDir() { + continue + } + found = true + dir := filepath.Join(root, entry.Name()) + fixture := loadBehaviorFixture(t, dir) + name := fixture.Name + if name == "" { + name = entry.Name() + } + t.Run(name, func(t *testing.T) { + t.Parallel() + runBehaviorFixture(t, dir, fixture) + }) + } + + if !found { + t.Fatalf("no behavior fixtures found under %s", root) + } +} + +func loadBehaviorFixture(t *testing.T, dir string) behaviorFixture { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "case.json")) + if err != nil { + t.Fatalf("ReadFile(case.json): %v", err) + } + var fixture behaviorFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatalf("Unmarshal(case.json): %v", err) + } + return fixture +} + +func runBehaviorFixture(t *testing.T, dir string, fixture behaviorFixture) { + t.Helper() + + source := readFixtureTextInDir(t, dir, fixture.SourceFile) + translator := &behaviorFixtureTranslator{ + t: t, + dir: dir, + rules: fixture.Rules, + } + + var ( + got string + err error + ) + + switch fixture.Mode { + case "doc_body_chunked": + got, err = translateDocBodyChunked(context.Background(), translator, fixture.RelPath, source, "en", "zh-CN") + case "frontmatter_scalar": + got, err = translateSnippet( + context.Background(), + translator, + &TranslationMemory{entries: map[string]TMEntry{}}, + fixture.RelPath+":frontmatter:title", + source, + "en", + "zh-CN", + ) + default: + t.Fatalf("unsupported fixture mode %q", fixture.Mode) + } + + if fixture.ExpectedErrorContains != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", fixture.ExpectedErrorContains) + } + if !strings.Contains(err.Error(), fixture.ExpectedErrorContains) { + t.Fatalf("expected error containing %q, got %v", fixture.ExpectedErrorContains, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fixture.ExpectedFile != "" { + want := readFixtureTextInDir(t, dir, fixture.ExpectedFile) + if normalizeBehaviorText(got) != normalizeBehaviorText(want) { + t.Fatalf("unexpected output\nwant:\n%s\n\ngot:\n%s", want, got) + } + } + + for _, fragment := range fixture.ExpectedOutputContains { + if !strings.Contains(got, fragment) { + t.Fatalf("expected output to contain %q\noutput:\n%s", fragment, got) + } + } + for _, fragment := range fixture.ExpectedOutputNotContains { + if strings.Contains(got, fragment) { + t.Fatalf("expected output to exclude %q\noutput:\n%s", fragment, got) + } + } +} + +func readFixtureText(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%q): %v", path, err) + } + return string(data) +} + +func readFixtureTextInDir(t *testing.T, dir, name string) string { + t.Helper() + if filepath.IsAbs(name) { + t.Fatalf("absolute fixture paths are not allowed: %q", name) + } + clean := filepath.Clean(name) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + t.Fatalf("fixture path escapes dir: %q", name) + } + return readFixtureText(t, filepath.Join(dir, clean)) +} + +func normalizeBehaviorText(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "\r\n", "\n")) +} diff --git a/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/case.json b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/case.json new file mode 100644 index 00000000000..b0a399ba55c --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/case.json @@ -0,0 +1,24 @@ +{ + "name": "fenced singleton retries after malformed raw output", + "mode": "doc_body_chunked", + "rel_path": "gateway/configuration-reference.md", + "source_file": "source.txt", + "expected_output_contains": [ + "Translated line 01", + "Translated line 02", + "Translated line 03", + "Translated line 04" + ], + "expected_output_not_contains": ["Line 01", "Line 02", "Line 03", "Line 04"], + "rules": [ + { + "method": "raw", + "match_all": ["Line 01", "Line 04"], + "response_file": "raw-malformed.txt" + }, + { + "method": "raw", + "replace_pairs": [{ "from": "Line ", "to": "Translated line " }] + } + ] +} diff --git a/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/raw-malformed.txt b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/raw-malformed.txt new file mode 100644 index 00000000000..dd40e712a74 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/raw-malformed.txt @@ -0,0 +1,6 @@ +```md +Line 01 +Line 02 +Line 03 +Line 04 + diff --git a/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/source.txt b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/source.txt new file mode 100644 index 00000000000..27990717eac --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/fenced-singleton-retry/source.txt @@ -0,0 +1,7 @@ +```md +Line 01 +Line 02 +Line 03 +Line 04 +``` + diff --git a/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/case.json b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/case.json new file mode 100644 index 00000000000..e8140a84c78 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/case.json @@ -0,0 +1,14 @@ +{ + "name": "frontmatter scalar falls back instead of keeping tagged wrapper", + "mode": "frontmatter_scalar", + "rel_path": "install/fly.md", + "source_file": "source.txt", + "expected_file": "expected.txt", + "rules": [ + { + "method": "masked", + "match_all": ["Deploying OpenClaw on Fly.io"], + "response_file": "masked-tagged-wrapper.txt" + } + ] +} diff --git a/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/expected.txt b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/expected.txt new file mode 100644 index 00000000000..5b65021bed5 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/expected.txt @@ -0,0 +1 @@ +Deploying OpenClaw on Fly.io diff --git a/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/masked-tagged-wrapper.txt b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/masked-tagged-wrapper.txt new file mode 100644 index 00000000000..664850c1904 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/masked-tagged-wrapper.txt @@ -0,0 +1,7 @@ + +title: Fly.io + + + +# Fly.io 部署 + diff --git a/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/source.txt b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/source.txt new file mode 100644 index 00000000000..5b65021bed5 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/frontmatter-fallback/source.txt @@ -0,0 +1 @@ +Deploying OpenClaw on Fly.io diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/case.json b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/case.json new file mode 100644 index 00000000000..24a3e8a4a84 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/case.json @@ -0,0 +1,25 @@ +{ + "name": "protocol leak retries on smaller chunks", + "mode": "doc_body_chunked", + "rel_path": "gateway/configuration-reference.md", + "source_file": "source.txt", + "expected_file": "expected.txt", + "expected_output_not_contains": ["", "", "[[[FM_"], + "rules": [ + { + "method": "raw", + "match_all": ["First chunk", "Second chunk"], + "response_file": "raw-leaked.txt" + }, + { + "method": "raw", + "match_all": ["First chunk"], + "response_file": "raw-first.txt" + }, + { + "method": "raw", + "match_all": ["Second chunk"], + "response_file": "raw-second.txt" + } + ] +} diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/expected.txt b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/expected.txt new file mode 100644 index 00000000000..43021911672 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/expected.txt @@ -0,0 +1,4 @@ +First translated + +Second translated + diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-first.txt b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-first.txt new file mode 100644 index 00000000000..a46c705da9c --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-first.txt @@ -0,0 +1,2 @@ +First translated + diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-leaked.txt b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-leaked.txt new file mode 100644 index 00000000000..34f6fe88dbb --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-leaked.txt @@ -0,0 +1,9 @@ + +title: leaked + + + +First translated + +Second translated + diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-second.txt b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-second.txt new file mode 100644 index 00000000000..77becbff0ca --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/raw-second.txt @@ -0,0 +1,2 @@ +Second translated + diff --git a/scripts/docs-i18n/testdata/behavior/protocol-leak-split/source.txt b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/source.txt new file mode 100644 index 00000000000..62a45e237bd --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/protocol-leak-split/source.txt @@ -0,0 +1,4 @@ +First chunk + +Second chunk + diff --git a/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/case.json b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/case.json new file mode 100644 index 00000000000..de2cf5090a7 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/case.json @@ -0,0 +1,14 @@ +{ + "name": "uppercase body wrapper is stripped", + "mode": "doc_body_chunked", + "rel_path": "help/testing.md", + "source_file": "source.txt", + "expected_file": "expected.txt", + "rules": [ + { + "method": "raw", + "match_all": ["Regular paragraph."], + "response_file": "raw-uppercase-wrapper.txt" + } + ] +} diff --git a/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/expected.txt b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/expected.txt new file mode 100644 index 00000000000..1259c46a24c --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/expected.txt @@ -0,0 +1 @@ +Translated paragraph. diff --git a/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/raw-uppercase-wrapper.txt b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/raw-uppercase-wrapper.txt new file mode 100644 index 00000000000..86fdd9f1559 --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/raw-uppercase-wrapper.txt @@ -0,0 +1,3 @@ + +Translated paragraph. + diff --git a/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/source.txt b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/source.txt new file mode 100644 index 00000000000..41237866e6b --- /dev/null +++ b/scripts/docs-i18n/testdata/behavior/uppercase-wrapper-strip/source.txt @@ -0,0 +1 @@ +Regular paragraph.