mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
a6f3e635d4
* feat(docs): add local authoring and resource workflows Add docs +script workflows for isolated draft initialization and tolerant XML/Markdown profiling. Support local and remote document resources across create and update flows with safe, bounded-concurrency uploads, binding verification, and cleanup. Synchronize shared credential-source selection during concurrent uploads, refresh lark-doc guidance, and expand unit, dry-run, and live E2E coverage. * docs(lark-doc): clarify genre reference paths * fix(docs): address PR validation feedback * docs(lark-doc): clarify remote image handling * feat: streamline docs draft workflow * fix(docs): clarify script input and resource cleanup * fix(docs): align script dry-run test with auth flow * fix(docs): authenticate local script e2e test * fix(docs): refine script diagnostics and image preflight * fix(docs): align draft workspace cleanup with VFS * fix(docs): route workspace cleanup through FileIO * docs(lark-doc): simplify profile check guidance
1318 lines
53 KiB
Go
1318 lines
53 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/larksuite/cli/shortcuts/doc/internal/docxparse"
|
|
)
|
|
|
|
func TestDocsScriptDoesNotExposeRemovedCommandsOrFlags(t *testing.T) {
|
|
foundCommand := false
|
|
for _, flag := range DocsScript.Flags {
|
|
switch flag.Name {
|
|
case "strict", "output", "overwrite", "file-name":
|
|
t.Fatalf("docs +script still exposes removed --%s flag", flag.Name)
|
|
case "command":
|
|
foundCommand = true
|
|
for _, value := range flag.Enum {
|
|
if value == "markdown-to-xml" || value == "create-temp-xml" {
|
|
t.Fatalf("docs +script still exposes removed %s command", value)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !foundCommand {
|
|
t.Fatal("docs +script does not expose --command")
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionFlagAcceptsFileAndStdin(t *testing.T) {
|
|
for _, flag := range DocsScript.Flags {
|
|
if flag.Name != "presentation-decision" {
|
|
continue
|
|
}
|
|
if len(flag.Input) != 2 || flag.Input[0] != common.File || flag.Input[1] != common.Stdin {
|
|
t.Fatalf("presentation-decision Input = %#v, want file and stdin", flag.Input)
|
|
}
|
|
for _, want := range []string{"genre_contract and adapter", `"none"`, "or null"} {
|
|
if !strings.Contains(flag.Desc, want) {
|
|
t.Fatalf("presentation-decision help = %q, want it to contain %q", flag.Desc, want)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
t.Fatal("docs +script does not expose --presentation-decision")
|
|
}
|
|
|
|
func TestDocsScriptParsesAndProfilesXML(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-test"))
|
|
source := `<title>标题</title><p>一个苹果是 an apple。</p>`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", source,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if !envelope.OK {
|
|
t.Fatalf("ok = false: %s", stdout)
|
|
}
|
|
if len(envelope.Data) != 2 || envelope.Data["profile"] == nil || envelope.Data["assessment"] == nil {
|
|
t.Fatalf("data = %+v, want assessment and profile", envelope.Data)
|
|
}
|
|
var assessment docsScriptAssessment
|
|
if err := json.Unmarshal(envelope.Data["assessment"], &assessment); err != nil {
|
|
t.Fatalf("decode assessment: %v", err)
|
|
}
|
|
if assessment.Status != docsScriptAssessmentPassed {
|
|
t.Fatalf("assessment = %+v for valid XML: %s", assessment, stdout)
|
|
}
|
|
var profile docsScriptPublicProfile
|
|
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
|
|
t.Fatalf("decode profile: %v", err)
|
|
}
|
|
var profileFields map[string]json.RawMessage
|
|
if err := json.Unmarshal(envelope.Data["profile"], &profileFields); err != nil {
|
|
t.Fatalf("decode profile fields: %v", err)
|
|
}
|
|
if len(profileFields) != 4 || profileFields["breakdown"] != nil {
|
|
t.Fatalf("profile fields = %+v, want breakdown hidden", profileFields)
|
|
}
|
|
if profile.WordCount != 10 || profile.CharCount != 15 || profile.BlockCount != 2 {
|
|
t.Fatalf("profile = %+v", profile)
|
|
}
|
|
if got := blockCount(profile.Blocks, "title"); got != 1 {
|
|
t.Fatalf("title count = %d, want 1", got)
|
|
}
|
|
if got := blockCount(profile.Blocks, "p"); got != 1 {
|
|
t.Fatalf("p count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptReturnsPresentationDecisionDiagnostics(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-warnings"))
|
|
decision := `{
|
|
"audience": "reader",
|
|
"reader_task": "understand the result and flow",
|
|
"genre_contract": "none",
|
|
"adapter": null,
|
|
"presentation_mode": "rich",
|
|
"word_count": {"min": 18, "max": 22},
|
|
"visual_plan": {
|
|
"reason": "visual explanation",
|
|
"blocks": [
|
|
{"type":"img","min_count":1,"purpose":"show the result"},
|
|
{"type":"whiteboard","min_count":1,"purpose":"show the flow"},
|
|
{"type":"html5-block","min_count":1,"purpose":"make the state explorable"}
|
|
]
|
|
}
|
|
}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>标题</title><p>一个苹果是 an apple。</p><img/>`,
|
|
"--presentation-decision", decision,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if !envelope.OK {
|
|
t.Fatalf("diagnostic result reported ok:false: %s", stdout)
|
|
}
|
|
if envelope.Data.Assessment.Status != docsScriptAssessmentFailed {
|
|
t.Fatalf("assessment = %+v with diagnostics, want failed: %s", envelope.Data.Assessment, stdout)
|
|
}
|
|
if len(envelope.Data.Diagnostics) != 3 {
|
|
t.Fatalf("diagnostics = %#v, want word-count, whiteboard, and html diagnostics", envelope.Data.Diagnostics)
|
|
}
|
|
wordCount := envelope.Data.Diagnostics[0]
|
|
if wordCount.Severity != docsScriptDiagnosticError || wordCount.Code != docsScriptCodeWordCountRange ||
|
|
wordCount.Expected == nil || wordCount.Expected.Min == nil || *wordCount.Expected.Min != 18 ||
|
|
wordCount.Expected.Max == nil || *wordCount.Expected.Max != 22 || wordCount.Actual == nil || *wordCount.Actual != 10 {
|
|
t.Fatalf("word-count diagnostic = %#v", wordCount)
|
|
}
|
|
for index, wantType := range []string{"whiteboard", "html5-block"} {
|
|
diagnostic := envelope.Data.Diagnostics[index+1]
|
|
if diagnostic.Code != docsScriptCodeRequiredBlock || diagnostic.Expected == nil ||
|
|
diagnostic.Expected.Type != wantType || diagnostic.Expected.MinCount != 1 ||
|
|
diagnostic.Actual == nil || *diagnostic.Actual != 0 {
|
|
t.Errorf("%s diagnostic = %#v", wantType, diagnostic)
|
|
}
|
|
}
|
|
for _, diagnostic := range envelope.Data.Diagnostics {
|
|
if diagnostic.Expected != nil && diagnostic.Expected.Type == "img" {
|
|
t.Fatalf("diagnostics = %#v, img requirement is satisfied", envelope.Data.Diagnostics)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionPreflightsBlockedRemoteImage(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-resource-preflight"))
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>Draft</title><img href="http://127.0.0.1/one.png"/><img href="http://127.0.0.1/two.png"/>`,
|
|
"--presentation-decision", decision,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data.Assessment.Status != docsScriptAssessmentFailed {
|
|
t.Fatalf("assessment = %+v, want failed", envelope.Data.Assessment)
|
|
}
|
|
if len(envelope.Data.Diagnostics) != 1 {
|
|
t.Fatalf("diagnostics = %#v, want one deduplicated image diagnostic", envelope.Data.Diagnostics)
|
|
}
|
|
diagnostic := envelope.Data.Diagnostics[0]
|
|
if diagnostic.Code != docsScriptCodeImageSource || diagnostic.Severity != docsScriptDiagnosticError ||
|
|
len(diagnostic.ImageIndices) != 2 || diagnostic.ImageIndices[0] != 1 || diagnostic.ImageIndices[1] != 2 ||
|
|
diagnostic.Msg != "local/internal host is not allowed" || !strings.Contains(diagnostic.Suggested, "Download the affected images") {
|
|
t.Fatalf("image diagnostic = %#v", diagnostic)
|
|
}
|
|
if strings.Contains(diagnostic.Msg, "remote image #") || strings.Contains(stdout.String(), "resource preflight failed") {
|
|
t.Fatalf("image diagnostic repeats legacy prefixes: %s", stdout)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionProbesRemoteImageDownload(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-resource-download-probe"))
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}`
|
|
|
|
originalDo := doRemoteDocImageRequest
|
|
t.Cleanup(func() { doRemoteDocImageRequest = originalDo })
|
|
requestMethod := ""
|
|
requestRange := ""
|
|
doRemoteDocImageRequest = func(_ remoteDocImageHTTPDoer, req *http.Request) (*http.Response, error) {
|
|
requestMethod = req.Method
|
|
requestRange = req.Header.Get("Range")
|
|
return &http.Response{
|
|
StatusCode: http.StatusNotFound,
|
|
Header: http.Header{"Content-Type": []string{"image/png"}},
|
|
Body: io.NopCloser(strings.NewReader("not found")),
|
|
}, nil
|
|
}
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>Draft</title><img href="https://93.184.216.34/image.png"/>`,
|
|
"--presentation-decision", decision,
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if requestMethod != http.MethodGet || requestRange != "bytes=0-0" {
|
|
t.Fatalf("remote image probe method = %q range = %q, want ranged GET", requestMethod, requestRange)
|
|
}
|
|
if envelope.Data.Assessment.Status != docsScriptAssessmentFailed {
|
|
t.Fatalf("assessment = %+v, want failed", envelope.Data.Assessment)
|
|
}
|
|
if len(envelope.Data.Diagnostics) != 1 || envelope.Data.Diagnostics[0].Code != docsScriptCodeImageUnavailable ||
|
|
len(envelope.Data.Diagnostics[0].ImageIndices) != 1 || envelope.Data.Diagnostics[0].ImageIndices[0] != 1 ||
|
|
envelope.Data.Diagnostics[0].Msg != "HTTP 404" {
|
|
t.Fatalf("diagnostics = %#v, want image #1 HTTP 404", envelope.Data.Diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptParseWithoutPresentationDecisionSkipsResourcePreflight(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-no-resource-preflight"))
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>Draft</title><img href="http://127.0.0.1/image.png"/>`,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data.Assessment.Status != docsScriptAssessmentPassed || len(envelope.Data.Diagnostics) != 0 {
|
|
t.Fatalf("result = %#v, want passed with no resource preflight without a Presentation Decision", envelope.Data)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptInitDraftPersistsDecisionForAutomaticParse(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft"))
|
|
decision := `{"audience":"reader","reader_task":"understand the result and flow","genre_contract":"none","adapter":null,"presentation_mode":"rich","word_count":{"min":18,"max":22},"visual_plan":{"reason":"visual explanation","blocks":[{"type":"img","min_count":1,"purpose":"show the result"},{"type":"whiteboard","min_count":1,"purpose":"show the flow"},{"type":"html5-block","min_count":1,"purpose":"make the state explorable"}]}}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", decision,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("initialize draft: %v", err)
|
|
}
|
|
var initialized struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &initialized); err != nil {
|
|
t.Fatalf("decode init output: %v\n%s", err, stdout)
|
|
}
|
|
if len(initialized.Data) != 3 || initialized.Data["workspace"] == nil || initialized.Data["draft_path"] == nil || initialized.Data["tip"] == nil {
|
|
t.Fatalf("init data = %#v, want workspace, draft_path, and tip", initialized.Data)
|
|
}
|
|
var result docsScriptDraftResult
|
|
rawResult, err := json.Marshal(initialized.Data)
|
|
if err != nil {
|
|
t.Fatalf("encode init data: %v", err)
|
|
}
|
|
if err := json.Unmarshal(rawResult, &result); err != nil {
|
|
t.Fatalf("decode init data: %v", err)
|
|
}
|
|
draftPath := result.DraftPath
|
|
if result.Tip != docsScriptDraftTip {
|
|
t.Fatalf("tip = %q, want %q", result.Tip, docsScriptDraftTip)
|
|
}
|
|
if result.Workspace != filepath.Dir(draftPath) {
|
|
t.Fatalf("workspace = %q, want parent of draft_path %q", result.Workspace, draftPath)
|
|
}
|
|
if filepath.IsAbs(result.Workspace) || filepath.Dir(result.Workspace) != "." {
|
|
t.Fatalf("workspace = %q, want a generated relative directory", result.Workspace)
|
|
}
|
|
if filepath.IsAbs(draftPath) || filepath.Dir(draftPath) == "." {
|
|
t.Fatalf("draft path = %q, want a relative path inside a generated workspace", draftPath)
|
|
}
|
|
if _, err := os.Stat(draftPath); !os.IsNotExist(err) {
|
|
t.Fatalf("reserved draft XML path already exists: %q, err=%v", draftPath, err)
|
|
}
|
|
savedDecision, err := os.ReadFile(filepath.Join(filepath.Dir(draftPath), docsScriptDecisionFile))
|
|
if err != nil {
|
|
t.Fatalf("read saved decision: %v", err)
|
|
}
|
|
if string(savedDecision) != decision {
|
|
t.Fatalf("saved decision = %q, want %q", savedDecision, decision)
|
|
}
|
|
if err := os.WriteFile(draftPath, []byte(`<title>标题</title><p>一个苹果是 an apple。</p><img/>`), 0o600); err != nil {
|
|
t.Fatalf("write draft: %v", err)
|
|
}
|
|
|
|
stdout.Reset()
|
|
err = mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", "@./" + draftPath,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
var parsed struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &parsed); err != nil {
|
|
t.Fatalf("decode parse output: %v\n%s", err, stdout)
|
|
}
|
|
if parsed.Data.Assessment.Status != docsScriptAssessmentFailed || len(parsed.Data.Diagnostics) != 3 {
|
|
t.Fatalf("result = %#v, want failed assessment with persisted word-count, whiteboard, and html diagnostics", parsed.Data)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptInitDraftRequiresPresentationDecision(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-required"))
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
|
|
entries, readErr := os.ReadDir(workDir)
|
|
if readErr != nil {
|
|
t.Fatalf("read work directory: %v", readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("failed init created files: %#v", entries)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionWordCountRangeIsInclusive(t *testing.T) {
|
|
minimum, maximum := 9, 11
|
|
decision := docsScriptPresentationDecision{WordCount: &docsScriptPresentationWordCount{Min: &minimum, Max: &maximum}}
|
|
tests := []struct {
|
|
wordCount int
|
|
wantWarn bool
|
|
}{
|
|
{wordCount: 8, wantWarn: true},
|
|
{wordCount: 9, wantWarn: false},
|
|
{wordCount: 10, wantWarn: false},
|
|
{wordCount: 11, wantWarn: false},
|
|
{wordCount: 12, wantWarn: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(fmt.Sprintf("word-count-%d", test.wordCount), func(t *testing.T) {
|
|
diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{WordCount: test.wordCount}, decision)
|
|
if got := len(diagnostics) > 0; got != test.wantWarn {
|
|
t.Fatalf("diagnostics = %#v, want diagnostic=%v", diagnostics, test.wantWarn)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionSupportsOneSidedWordCountBounds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
wordCount int
|
|
decision string
|
|
want string
|
|
}{
|
|
{
|
|
name: "minimum",
|
|
wordCount: 9,
|
|
decision: `{"audience":"reader","reader_task":"understand","genre_contract":"knowledge","adapter":null,"presentation_mode":"normal","word_count":{"min":10,"max":null},"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`,
|
|
want: "Increase word_count to at least 10.",
|
|
},
|
|
{
|
|
name: "maximum",
|
|
wordCount: 21,
|
|
decision: `{"audience":"reader","reader_task":"understand","genre_contract":"knowledge","adapter":"wechat","presentation_mode":"normal","word_count":{"min":null,"max":20},"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`,
|
|
want: "Reduce word_count to at most 20.",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
decision, err := parseDocsScriptPresentationDecision(test.decision)
|
|
if err != nil {
|
|
t.Fatalf("parse decision: %v", err)
|
|
}
|
|
diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{WordCount: test.wordCount}, decision)
|
|
if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeWordCountRange || diagnostics[0].Suggested != test.want {
|
|
t.Fatalf("diagnostics = %#v, want one with suggestion %q", diagnostics, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionListCountsULAndOL(t *testing.T) {
|
|
decision, err := parseDocsScriptPresentationDecision(`{
|
|
"audience":"reader",
|
|
"reader_task":"review recommendations",
|
|
"genre_contract":"none",
|
|
"adapter":null,
|
|
"presentation_mode":"normal",
|
|
"visual_plan":{
|
|
"reason":"group recommendations",
|
|
"blocks":[{"type":"list","min_count":2,"purpose":"separate equipment and food recommendations"}]
|
|
}
|
|
}`)
|
|
if err != nil {
|
|
t.Fatalf("parse list Presentation Decision: %v", err)
|
|
}
|
|
profile := docsScriptPublicProfile{Blocks: []docxparse.BlockShare{
|
|
{Type: "ul", Count: 1},
|
|
{Type: "ol", Count: 1},
|
|
}}
|
|
if diagnostics := docsScriptPresentationDiagnostics(profile, decision); len(diagnostics) != 0 {
|
|
t.Fatalf("diagnostics = %#v, want ul + ol to satisfy two list blocks", diagnostics)
|
|
}
|
|
|
|
decision.VisualPlan.Blocks[0].MinCount = 3
|
|
diagnostics := docsScriptPresentationDiagnostics(profile, decision)
|
|
if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeRequiredBlock ||
|
|
diagnostics[0].Expected == nil || diagnostics[0].Expected.Type != docsScriptListBlockType ||
|
|
diagnostics[0].Expected.MinCount != 3 || diagnostics[0].Actual == nil || *diagnostics[0].Actual != 2 {
|
|
t.Fatalf("diagnostics = %#v, want list min_count 3 and actual 2", diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptOmitsDiagnosticsWhenPresentationDecisionPasses(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
if err := os.WriteFile("flow.mmd", []byte("flowchart LR\nA --> B"), 0o600); err != nil {
|
|
t.Fatalf("write whiteboard source: %v", err)
|
|
}
|
|
if err := os.WriteFile("widget.html", []byte("<html><body>status</body></html>"), 0o600); err != nil {
|
|
t.Fatalf("write HTML source: %v", err)
|
|
}
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-pass"))
|
|
decision := `{
|
|
"audience": "reader",
|
|
"reader_task": "understand the result and flow",
|
|
"genre_contract": "none",
|
|
"adapter": null,
|
|
"presentation_mode": "rich",
|
|
"word_count": {"min": 9, "max": 11},
|
|
"visual_plan": {
|
|
"reason": "visual explanation",
|
|
"blocks": [
|
|
{"type":"img","min_count":1,"purpose":"show the result"},
|
|
{"type":"whiteboard","min_count":1,"purpose":"show the flow"},
|
|
{"type":"html5-block","min_count":1,"purpose":"make the state explorable"}
|
|
]
|
|
}
|
|
}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>标题</title><p>一个苹果是 an apple。</p><img/><whiteboard type="mermaid" path="@flow.mmd"></whiteboard><html5-block path="@widget.html"></html5-block>`,
|
|
"--presentation-decision", decision,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data["diagnostics"] != nil {
|
|
t.Fatalf("passing decision should omit data.diagnostics: %s", stdout)
|
|
}
|
|
var assessment docsScriptAssessment
|
|
if err := json.Unmarshal(envelope.Data["assessment"], &assessment); err != nil || assessment.Status != docsScriptAssessmentPassed {
|
|
t.Fatalf("passing decision data.assessment = %s, decode error = %v", envelope.Data["assessment"], err)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptRejectsInvalidPresentationDecision(t *testing.T) {
|
|
validPrefix := `{"audience":"reader","reader_task":"understand the document","genre_contract":"none","adapter":null,"presentation_mode":"normal",`
|
|
tests := []struct {
|
|
name string
|
|
decision string
|
|
}{
|
|
{name: "invalid json", decision: `{`},
|
|
{name: "unknown field", decision: validPrefix + `"visual_plan":{"reason":"plain is enough","blocks":[],"image_enabled":true}}`},
|
|
{name: "legacy fields", decision: `{"target":"reader","genre_contract":"none","adapter":"none","presentation_mode":"normal","hard_rule":"","word_count":20,"visual_plan":{"reason":"plain is enough","exception":"","blocks":[]}}`},
|
|
{name: "integer word count", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":20,"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "missing word count bound", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "empty word count range", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":null,"max":null},"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "non-positive word count", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":0,"max":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "reversed word count range", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":{"min":20,"max":10},"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "missing required field", decision: `{"reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "missing adapter", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "empty genre contract", decision: `{"audience":"reader","reader_task":"understand","genre_contract":" ","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "empty adapter", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":" ","presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "removed hard_rules field", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","hard_rules":[],"visual_plan":{"reason":"plain is enough","blocks":[]}}`},
|
|
{name: "invalid presentation mode", decision: `{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"decorative","visual_plan":{"reason":"visual","blocks":[]}}`},
|
|
{name: "null block plan", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":null}}`},
|
|
{name: "unknown block type", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"future-widget","min_count":1,"purpose":"show the result"}]}}`},
|
|
{name: "ordinary text block is not presentation block", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"p","min_count":2,"purpose":"fill the quota"}]}}`},
|
|
{name: "non-positive block minimum", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":0,"purpose":"show the result"}]}}`},
|
|
{name: "missing block purpose", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":1,"purpose":""}]}}`},
|
|
{name: "duplicate block type", decision: validPrefix + `"visual_plan":{"reason":"visual","blocks":[{"type":"img","min_count":1,"purpose":"one"},{"type":"img","min_count":1,"purpose":"two"}]}}`},
|
|
{name: "multiple values", decision: `{} {}`},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-presentation-invalid"))
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<p>text</p>`,
|
|
"--presentation-decision", test.decision,
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionAllowsNoneOrNullRoutes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
genreContract string
|
|
adapter string
|
|
wantGenreNil bool
|
|
wantAdapterNil bool
|
|
}{
|
|
{name: "both none", genreContract: `"none"`, adapter: `"none"`},
|
|
{name: "both null", genreContract: `null`, adapter: `null`, wantGenreNil: true, wantAdapterNil: true},
|
|
{name: "null genre", genreContract: `null`, adapter: `"none"`, wantGenreNil: true},
|
|
{name: "null adapter", genreContract: `"none"`, adapter: `null`, wantAdapterNil: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
decision, err := parseDocsScriptPresentationDecision(fmt.Sprintf(
|
|
`{"audience":"reader","reader_task":"understand","genre_contract":%s,"adapter":%s,"presentation_mode":"normal","visual_plan":{"reason":"plain is enough","blocks":[]}}`,
|
|
test.genreContract,
|
|
test.adapter,
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("parseDocsScriptPresentationDecision() error = %v", err)
|
|
}
|
|
if got := decision.GenreContract == nil; got != test.wantGenreNil {
|
|
t.Fatalf("GenreContract nil = %v, want %v", got, test.wantGenreNil)
|
|
}
|
|
if got := decision.Adapter == nil; got != test.wantAdapterNil {
|
|
t.Fatalf("Adapter nil = %v, want %v", got, test.wantAdapterNil)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionRejectsNullWordCountWithOmitGuidance(t *testing.T) {
|
|
_, err := parseDocsScriptPresentationDecision(`{"audience":"reader","reader_task":"understand","genre_contract":"none","adapter":null,"presentation_mode":"normal","word_count":null,"visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("error does not expose a typed problem: %v", err)
|
|
}
|
|
if got, want := problem.Message, "--presentation-decision word_count must be omitted when no word-count requirement was requested"; got != want {
|
|
t.Fatalf("message = %q, want %q", got, want)
|
|
}
|
|
if got, want := problem.Hint, "remove the word_count field; include it only when the user requested a word-count bound"; got != want {
|
|
t.Fatalf("hint = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptRichPresentationDecisionAllowsAnyBlockCount(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
decision string
|
|
}{
|
|
{
|
|
name: "no planned blocks",
|
|
decision: `{"audience":"reader","reader_task":"understand the topic","genre_contract":"knowledge","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"the scan found no presentation block that would improve the reader task","blocks":[]}}`,
|
|
},
|
|
{
|
|
name: "one planned block",
|
|
decision: `{"audience":"reader","reader_task":"understand the relationship","genre_contract":"knowledge","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"one diagram is sufficient","blocks":[{"type":"whiteboard","min_count":1,"purpose":"show the relationship"}]}}`,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if _, err := parseDocsScriptPresentationDecision(test.decision); err != nil {
|
|
t.Fatalf("parseDocsScriptPresentationDecision() error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationDecisionRejectsRemovedHardRulesField(t *testing.T) {
|
|
_, err := parseDocsScriptPresentationDecision(`{
|
|
"audience":"executives",
|
|
"reader_task":"approve or reject the proposal",
|
|
"genre_contract":"formal-doc",
|
|
"adapter":null,
|
|
"presentation_mode":"formal",
|
|
"hard_rules":["do not use callout blocks","include a risks section"],
|
|
"visual_plan":{
|
|
"reason":"a restrained structure best supports formal approval",
|
|
"blocks":[]
|
|
}
|
|
}`)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--presentation-decision")
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || !strings.Contains(problem.Message, `unknown field "hard_rules"`) {
|
|
t.Fatalf("error = %v, want removed hard_rules field to be rejected", err)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptPresentationBlockPlanUsesProfileCatalogGenerically(t *testing.T) {
|
|
decision, err := parseDocsScriptPresentationDecision(`{
|
|
"audience":"reviewers",
|
|
"reader_task":"compare both cohorts",
|
|
"genre_contract":"report",
|
|
"adapter":null,
|
|
"presentation_mode":"normal",
|
|
"visual_plan":{
|
|
"reason":"compare exact fields",
|
|
"blocks":[{"type":"table","min_count":2,"purpose":"compare both cohorts"}]
|
|
}
|
|
}`)
|
|
if err != nil {
|
|
t.Fatalf("parseDocsScriptPresentationDecision() error: %v", err)
|
|
}
|
|
diagnostics := docsScriptPresentationDiagnostics(docsScriptPublicProfile{Blocks: []docxparse.BlockShare{{Type: "table", Count: 1}}}, decision)
|
|
if len(diagnostics) != 1 || diagnostics[0].Code != docsScriptCodeRequiredBlock ||
|
|
diagnostics[0].Expected == nil || diagnostics[0].Expected.Type != "table" || diagnostics[0].Expected.MinCount != 2 ||
|
|
diagnostics[0].Actual == nil || *diagnostics[0].Actual != 1 {
|
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptRejectsMarkdownContent(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-reject-markdown"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", "# 标题\n\n- item",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != "--content" {
|
|
t.Fatalf("error = %T %v, problem = %#v, validation = %#v", err, err, problem, validationErr)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptParsesOnlineDocumentFromToken(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-token"))
|
|
registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnScriptToken/fetch", map[string]interface{}{
|
|
"document": map[string]interface{}{
|
|
"document_id": "doxcnScriptToken",
|
|
"content": `<title>在线文档</title><p>Hello world</p>`,
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--doc", "doxcnScriptToken",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script with token: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data.Profile.BlockCount != 2 {
|
|
t.Fatalf("profile = %+v, want 2 blocks", envelope.Data.Profile)
|
|
}
|
|
if got := blockCount(envelope.Data.Profile.Blocks, "title"); got != 1 {
|
|
t.Fatalf("title count = %d, want 1", got)
|
|
}
|
|
if got := blockCount(envelope.Data.Profile.Blocks, "p"); got != 1 {
|
|
t.Fatalf("p count = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptParsesOnlineDocumentFromURL(t *testing.T) {
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-url"))
|
|
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/wikcnScriptURL/fetch", map[string]interface{}{
|
|
"document": map[string]interface{}{
|
|
"document_id": "doxcnResolvedScriptURL",
|
|
"content": `<p>从 Wiki URL 读取</p>`,
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--doc", "https://example.larksuite.com/wiki/wikcnScriptURL",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script with URL: %v", err)
|
|
}
|
|
if stub.CapturedBody == nil {
|
|
t.Fatal("online parse did not call the document fetch API")
|
|
}
|
|
|
|
var envelope struct {
|
|
Data docsScriptParseResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data.Profile.BlockCount != 1 || blockCount(envelope.Data.Profile.Blocks, "p") != 1 {
|
|
t.Fatalf("profile = %+v, want one paragraph", envelope.Data.Profile)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptRejectsContentAndDocTogether(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-input-conflict"))
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<p>local</p>`,
|
|
"--doc", "doxcnScriptConflict",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "", "--content", "--doc")
|
|
}
|
|
|
|
func TestDocsScriptInitDraftCreatesUniqueWorkspacesWithoutXML(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-unique-drafts"))
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`
|
|
|
|
initialize := func() docsScriptDraftResult {
|
|
t.Helper()
|
|
stdout.Reset()
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", decision,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script: %v", err)
|
|
}
|
|
var envelope struct {
|
|
Data docsScriptDraftResult `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
return envelope.Data
|
|
}
|
|
|
|
first := initialize()
|
|
second := initialize()
|
|
if first.DraftPath == second.DraftPath {
|
|
t.Fatalf("draft paths are identical: %q", first.DraftPath)
|
|
}
|
|
for _, got := range []docsScriptDraftResult{first, second} {
|
|
directory := filepath.Dir(got.DraftPath)
|
|
randomPart := strings.TrimSuffix(strings.TrimPrefix(directory, docsScriptDraftDirectoryPrefix), docsScriptDraftDirectorySuffix)
|
|
if got.Workspace != directory || got.Tip != docsScriptDraftTip || filepath.Base(got.DraftPath) != docsScriptDraftXMLFileName || filepath.Base(directory) != directory ||
|
|
!strings.HasPrefix(directory, docsScriptDraftDirectoryPrefix) || !strings.HasSuffix(directory, docsScriptDraftDirectorySuffix) {
|
|
t.Fatalf("result = %+v, want draft_<random>_folder/draft.xml with an uncreated draft", got)
|
|
}
|
|
if len(randomPart) != docsScriptDraftRandomHexLength {
|
|
t.Fatalf("workspace random suffix = %q, want %d hex characters", randomPart, docsScriptDraftRandomHexLength)
|
|
}
|
|
if _, err := os.Stat(got.DraftPath); !os.IsNotExist(err) {
|
|
t.Fatalf("reserved draft XML path already exists: %q, err=%v", got.DraftPath, err)
|
|
}
|
|
savedDecision, err := os.ReadFile(filepath.Join(directory, docsScriptDecisionFile))
|
|
if err != nil {
|
|
t.Fatalf("read saved decision for %q: %v", got.DraftPath, err)
|
|
}
|
|
if string(savedDecision) != decision {
|
|
t.Fatalf("saved decision = %q, want %q", savedDecision, decision)
|
|
}
|
|
}
|
|
}
|
|
|
|
type docsScriptFailingFileIO struct {
|
|
fileio.WorkspaceFileIO
|
|
failSaveAt int
|
|
saveCalls int
|
|
removeCalls []string
|
|
}
|
|
|
|
func (f *docsScriptFailingFileIO) Save(path string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
|
f.saveCalls++
|
|
if f.saveCalls == f.failSaveAt {
|
|
return nil, errors.New("injected draft workspace save failure")
|
|
}
|
|
return f.WorkspaceFileIO.Save(path, opts, body)
|
|
}
|
|
|
|
func (f *docsScriptFailingFileIO) RemoveWorkspaceEntry(path string) error {
|
|
f.removeCalls = append(f.removeCalls, path)
|
|
return f.WorkspaceFileIO.RemoveWorkspaceEntry(path)
|
|
}
|
|
|
|
type docsScriptFileIOProvider struct {
|
|
fileIO fileio.FileIO
|
|
}
|
|
|
|
func (p docsScriptFileIOProvider) Name() string { return "docs-script-test" }
|
|
|
|
func (p docsScriptFileIOProvider) ResolveFileIO(context.Context) fileio.FileIO { return p.fileIO }
|
|
|
|
type docsScriptErrorWriter struct{}
|
|
|
|
func (docsScriptErrorWriter) Write([]byte) (int, error) {
|
|
return 0, errors.New("injected output failure")
|
|
}
|
|
|
|
func TestDocsScriptRemovesOnlyCurrentWorkspaceOnFinalFailure(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-cleanup-failed-workspace"))
|
|
baseFileIO, ok := f.ResolveFileIO(context.Background()).(fileio.WorkspaceFileIO)
|
|
if !ok {
|
|
t.Fatalf("default FileIO does not implement fileio.WorkspaceFileIO")
|
|
}
|
|
failingFileIO := &docsScriptFailingFileIO{
|
|
WorkspaceFileIO: baseFileIO,
|
|
failSaveAt: 1,
|
|
}
|
|
f.FileIOProvider = docsScriptFileIOProvider{fileIO: failingFileIO}
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", decision,
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("execute docs +script succeeded, want injected save failure")
|
|
}
|
|
entries, readErr := os.ReadDir(workDir)
|
|
if readErr != nil {
|
|
t.Fatalf("read work directory: %v", readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("failed initialization left workspace entries: %+v", entries)
|
|
}
|
|
|
|
f.IOStreams.Out = docsScriptErrorWriter{}
|
|
err = mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", decision,
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("execute docs +script succeeded, want injected output failure")
|
|
}
|
|
entries, readErr = os.ReadDir(workDir)
|
|
if readErr != nil {
|
|
t.Fatalf("read work directory after output failure: %v", readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("output failure left workspace entries: %+v", entries)
|
|
}
|
|
|
|
if err := os.MkdirAll("unrelated_folder", 0o700); err != nil {
|
|
t.Fatalf("create unrelated directory: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("unrelated_folder", docsScriptDraftXMLFileName), []byte("keep"), 0o600); err != nil {
|
|
t.Fatalf("create unrelated file: %v", err)
|
|
}
|
|
unrelatedPath := filepath.Join("unrelated_folder", docsScriptDraftXMLFileName)
|
|
removeCallsBefore := len(failingFileIO.removeCalls)
|
|
if err := (docsScriptWorkspace{
|
|
path: unrelatedPath,
|
|
fileIO: failingFileIO,
|
|
}).remove(); err == nil {
|
|
t.Fatal("workspace cleanup accepted an unrelated directory")
|
|
}
|
|
if len(failingFileIO.removeCalls) != removeCallsBefore {
|
|
t.Fatalf("unsafe workspace reached FileIO removal: %v", failingFileIO.removeCalls[removeCallsBefore:])
|
|
}
|
|
if _, err := os.Stat(filepath.Join("unrelated_folder", docsScriptDraftXMLFileName)); err != nil {
|
|
t.Fatalf("unrelated file was removed: %v", err)
|
|
}
|
|
if len(failingFileIO.removeCalls) != 4 {
|
|
t.Fatalf("workspace FileIO removal calls = %v, want two entries for each failed initialization", failingFileIO.removeCalls)
|
|
}
|
|
for i := 0; i < len(failingFileIO.removeCalls); i += 2 {
|
|
decisionPath := failingFileIO.removeCalls[i]
|
|
directory := failingFileIO.removeCalls[i+1]
|
|
if filepath.Base(decisionPath) != docsScriptDecisionFile || filepath.Dir(decisionPath) != directory {
|
|
t.Fatalf("workspace cleanup pair = %q, %q", decisionPath, directory)
|
|
}
|
|
if !isDocsScriptWorkspacePath(filepath.Join(directory, docsScriptDraftXMLFileName)) {
|
|
t.Fatalf("workspace cleanup used unexpected directory %q", directory)
|
|
}
|
|
}
|
|
}
|
|
|
|
type docsScriptBasicFileIO struct {
|
|
fileio.FileIO
|
|
}
|
|
|
|
func TestDocsScriptInitDraftRequiresWorkspaceFileIO(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-workspace-fileio"))
|
|
f.FileIOProvider = docsScriptFileIOProvider{fileIO: docsScriptBasicFileIO{
|
|
FileIO: f.ResolveFileIO(context.Background()),
|
|
}}
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", decision,
|
|
}, f, nil)
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
|
|
t.Fatalf("problem = %+v, ok=%v, want validation/failed_precondition", problem, ok)
|
|
}
|
|
entries, readErr := os.ReadDir(workDir)
|
|
if readErr != nil {
|
|
t.Fatalf("read work directory: %v", readErr)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("unsupported FileIO created workspace entries: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptInitDraftRejectsContentAndDoc(t *testing.T) {
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"plain text is sufficient","blocks":[]}}`
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
param string
|
|
}{
|
|
{name: "content", args: []string{"--content", "<p>text</p>"}, param: "--content"},
|
|
{name: "doc", args: []string{"--doc", "doxcnScriptTemp"}, param: "--doc"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-flags"))
|
|
args := []string{"+script", "--command", docsScriptInitDraft, "--presentation-decision", decision, "--as", "bot"}
|
|
args = append(args, test.args...)
|
|
err := mountAndRunDocs(t, DocsScript, args, f, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected %s validation error", test.param)
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument ||
|
|
!errors.As(err, &validationErr) || validationErr.Param != test.param {
|
|
t.Fatalf("problem = %+v, validation = %+v, ok=%v", problem, validationErr, ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptDryRunHasNoAPICall(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-dry-run"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<p>text</p>`,
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script dry-run: %v", err)
|
|
}
|
|
var got struct {
|
|
API []any `json:"api"`
|
|
Command string `json:"command"`
|
|
Network bool `json:"network"`
|
|
}
|
|
decodeDocsScriptDryRun(t, stdout, &got)
|
|
if len(got.API) != 0 || got.Command != docsScriptParse || got.Network {
|
|
t.Fatalf("dry-run output = %+v", got)
|
|
}
|
|
var envelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode dry-run envelope: %v", err)
|
|
}
|
|
if _, exists := envelope.Data["strict"]; exists {
|
|
t.Fatalf("dry-run output still exposes removed strict mode: %s", stdout)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptRemoteImagePreflightDryRunDeclaresNetwork(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-remote-preflight-dry-run"))
|
|
decision := `{"audience":"reader","reader_task":"read the draft","genre_contract":"none","adapter":null,"presentation_mode":"normal","visual_plan":{"reason":"an image provides visual evidence","blocks":[{"type":"img","min_count":1,"purpose":"show visual evidence"}]}}`
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>Draft</title><img href="https://93.184.216.34/image.png"/>`,
|
|
"--presentation-decision", decision,
|
|
"--dry-run",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute docs +script dry-run: %v", err)
|
|
}
|
|
var got struct {
|
|
Command string `json:"command"`
|
|
Network bool `json:"network"`
|
|
}
|
|
decodeDocsScriptDryRun(t, stdout, &got)
|
|
if got.Command != docsScriptParse || !got.Network {
|
|
t.Fatalf("dry-run output = %+v, want network=true for remote image preflight", got)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptDryRunRejectsInvalidSavedPresentationDecision(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
workspace := filepath.Join("draft_workspace")
|
|
if err := os.MkdirAll(workspace, 0o700); err != nil {
|
|
t.Fatalf("create draft workspace: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(workspace, "draft.xml"), []byte(`<p>draft</p>`), 0o600); err != nil {
|
|
t.Fatalf("write draft XML: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(workspace, docsScriptDecisionFile), []byte(`{"invalid":true}`), 0o600); err != nil {
|
|
t.Fatalf("write invalid saved Presentation Decision: %v", err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-invalid-sidecar-dry-run"))
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", "@" + filepath.Join(workspace, "draft.xml"),
|
|
"--dry-run",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("execute docs +script dry-run succeeded with an invalid saved Presentation Decision")
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Subtype != errs.SubtypeUnknown {
|
|
t.Fatalf("error = %T %v, problem = %#v; want typed invalid saved decision error", err, err, problem)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptInitDraftDryRunDoesNotWrite(t *testing.T) {
|
|
workDir := t.TempDir()
|
|
withDocsWorkingDir(t, workDir)
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-init-draft-dry-run"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptInitDraft,
|
|
"--presentation-decision", `{"audience":"reader","reader_task":"understand key states","genre_contract":"none","adapter":null,"presentation_mode":"rich","visual_plan":{"reason":"visual explanation","blocks":[{"type":"img","min_count":2,"purpose":"show key states"}]}}`,
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute init-draft dry-run: %v", err)
|
|
}
|
|
var got struct {
|
|
API []any `json:"api"`
|
|
Command string `json:"command"`
|
|
PresentationDecision bool `json:"presentation_decision"`
|
|
CreatesWorkspace bool `json:"creates_workspace"`
|
|
CreatesDraftFile bool `json:"creates_draft_file"`
|
|
DirectoryPattern string `json:"directory_pattern"`
|
|
XMLFileName string `json:"xml_file_name"`
|
|
Network bool `json:"network"`
|
|
}
|
|
decodeDocsScriptDryRun(t, stdout, &got)
|
|
if len(got.API) != 0 || got.Command != docsScriptInitDraft || !got.PresentationDecision ||
|
|
!got.CreatesWorkspace || got.CreatesDraftFile || got.DirectoryPattern != docsScriptDraftDirectoryPattern ||
|
|
got.XMLFileName != docsScriptDraftXMLFileName || got.Network {
|
|
t.Fatalf("dry-run output = %+v", got)
|
|
}
|
|
entries, err := os.ReadDir(workDir)
|
|
if err != nil {
|
|
t.Fatalf("read work directory: %v", err)
|
|
}
|
|
if len(entries) != 0 {
|
|
t.Fatalf("dry-run created files: %+v", entries)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptOnlineDryRunShowsFetchAPICall(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-online-dry-run"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--doc", "https://example.larksuite.com/docx/doxcnScriptDryRun",
|
|
"--dry-run",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute online docs +script dry-run: %v", err)
|
|
}
|
|
var got struct {
|
|
API []struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Body map[string]interface{} `json:"body"`
|
|
} `json:"api"`
|
|
Command string `json:"command"`
|
|
DocumentID string `json:"document_id"`
|
|
Network bool `json:"network"`
|
|
}
|
|
decodeDocsScriptDryRun(t, stdout, &got)
|
|
if len(got.API) != 1 || got.API[0].Method != "POST" ||
|
|
got.API[0].URL != "/open-apis/docs_ai/v1/documents/doxcnScriptDryRun/fetch" {
|
|
t.Fatalf("dry-run API = %+v", got.API)
|
|
}
|
|
if got.API[0].Body["format"] != "xml" {
|
|
t.Fatalf("dry-run body = %+v, want XML fetch", got.API[0].Body)
|
|
}
|
|
if got.Command != docsScriptParse || got.DocumentID != "doxcnScriptDryRun" || !got.Network {
|
|
t.Fatalf("dry-run output = %+v", got)
|
|
}
|
|
}
|
|
|
|
func decodeDocsScriptDryRun(t *testing.T, stdout *bytes.Buffer, dst interface{}) {
|
|
t.Helper()
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
DryRun bool `json:"dry_run"`
|
|
Data json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode dry-run envelope: %v\n%s", err, stdout)
|
|
}
|
|
if !envelope.OK || !envelope.DryRun {
|
|
t.Fatalf("unexpected dry-run envelope: %s", stdout)
|
|
}
|
|
if err := json.Unmarshal(envelope.Data, dst); err != nil {
|
|
t.Fatalf("decode dry-run data: %v\n%s", err, stdout)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptReturnsTypedParseError(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-error"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<!DOCTYPE document><p>text</p>`,
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected parse error")
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Fatalf("problem = %+v, ok=%v", problem, ok)
|
|
}
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) || validationErr.Param != "--content" {
|
|
t.Fatalf("error = %#v, want --content metadata", err)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptProfilesCompatibleMalformedXML(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-malformed"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>T</title><ul><li>one<li>two</ul><p>tail</p`,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute compatible parse: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if len(envelope.Data) != 2 || envelope.Data["profile"] == nil || envelope.Data["assessment"] == nil {
|
|
t.Fatalf("data = %+v, want assessment and profile", envelope.Data)
|
|
}
|
|
var assessment docsScriptAssessment
|
|
if err := json.Unmarshal(envelope.Data["assessment"], &assessment); err != nil || assessment.Status != docsScriptAssessmentPassed {
|
|
t.Fatalf("compatible XML data.assessment = %s, decode error = %v", envelope.Data["assessment"], err)
|
|
}
|
|
var profile docsScriptPublicProfile
|
|
if err := json.Unmarshal(envelope.Data["profile"], &profile); err != nil {
|
|
t.Fatalf("decode profile: %v", err)
|
|
}
|
|
if profile.BlockCount != 5 || blockCount(profile.Blocks, "li") != 2 || blockCount(profile.Blocks, "p") != 1 {
|
|
t.Fatalf("profile = %+v, want title, ul, two li, and p", profile)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptAcceptsLocalImagePath(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-script-local-image"))
|
|
|
|
err := mountAndRunDocs(t, DocsScript, []string{
|
|
"+script",
|
|
"--command", docsScriptParse,
|
|
"--content", `<title>Local image</title><img path="@diagram.png" caption="diagram"/>`,
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("execute parse: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
Data struct {
|
|
Profile docsScriptPublicProfile `json:"profile"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode stdout: %v\n%s", err, stdout)
|
|
}
|
|
if envelope.Data.Profile.BlockCount != 2 || blockCount(envelope.Data.Profile.Blocks, "img") != 1 {
|
|
t.Fatalf("profile = %+v, want one title and one img block", envelope.Data.Profile)
|
|
}
|
|
}
|
|
|
|
func TestDocsScriptHelpExamplesAreCrossShellSafe(t *testing.T) {
|
|
cmd := &cobra.Command{Short: "local document parser"}
|
|
installDocsScriptHelp(cmd)
|
|
if strings.Contains(cmd.Example, "cat ") {
|
|
t.Fatalf("help examples require a platform-specific command: %q", cmd.Example)
|
|
}
|
|
if strings.Contains(cmd.Example, "--content @") {
|
|
t.Fatalf("help examples contain an unquoted @file argument: %q", cmd.Example)
|
|
}
|
|
for _, want := range []string{`--command init-draft --presentation-decision`, `--content "@./draft.xml"`} {
|
|
if !strings.Contains(cmd.Example, want) {
|
|
t.Errorf("help examples missing %q: %q", want, cmd.Example)
|
|
}
|
|
}
|
|
if strings.Contains(cmd.Example, `@./draft.md`) {
|
|
t.Fatalf("help examples contain unsupported Markdown input: %q", cmd.Example)
|
|
}
|
|
}
|
|
|
|
func blockCount(blocks []docxparse.BlockShare, typ string) int {
|
|
for _, block := range blocks {
|
|
if block.Type == typ {
|
|
return block.Count
|
|
}
|
|
}
|
|
return 0
|
|
}
|