From c38914bb4901f10d6bc8388f89ac412d210156a2 Mon Sep 17 00:00:00 2001 From: "sunpeiyang.996" Date: Thu, 16 Jul 2026 22:02:33 +0800 Subject: [PATCH 1/9] 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. --- cmd/root_test.go | 9 +- go.mod | 2 + go.sum | 4 + internal/credential/credential_provider.go | 26 +- shortcuts/common/runner.go | 86 +- .../common/runner_error_presenter_test.go | 2 +- .../common/runner_partial_failure_test.go | 27 + shortcuts/common/types.go | 12 +- shortcuts/doc/doc_media_insert.go | 23 +- shortcuts/doc/docs_create.go | 52 +- shortcuts/doc/docs_create_test.go | 61 + shortcuts/doc/docs_create_v2.go | 50 +- shortcuts/doc/docs_result_test.go | 70 + shortcuts/doc/docs_script.go | 748 +++++ shortcuts/doc/docs_script_test.go | 1318 ++++++++ shortcuts/doc/docs_update.go | 29 +- shortcuts/doc/docs_update_test.go | 47 + shortcuts/doc/docs_update_v2.go | 67 +- shortcuts/doc/helpers.go | 13 +- shortcuts/doc/html5_block_resources.go | 42 +- .../doc/internal/docxparse/block_catalog.go | 116 + .../internal/docxparse/block_catalog_test.go | 32 + shortcuts/doc/internal/docxparse/errors.go | 18 + shortcuts/doc/internal/docxparse/markdown.go | 935 ++++++ .../doc/internal/docxparse/markdown_cjk.go | 234 ++ .../internal/docxparse/markdown_extensions.go | 364 +++ shortcuts/doc/internal/docxparse/model.go | 172 + .../doc/internal/docxparse/parse_test.go | 1235 ++++++++ shortcuts/doc/internal/docxparse/profile.go | 447 +++ shortcuts/doc/internal/docxparse/wordcount.go | 352 ++ shortcuts/doc/internal/docxparse/xml.go | 797 +++++ .../doc/internal/docxparse/xml_compat.go | 69 + shortcuts/doc/local_doc_resources.go | 2820 +++++++++++++++++ shortcuts/doc/local_doc_resources_test.go | 1955 ++++++++++++ shortcuts/doc/shortcuts.go | 1 + skills/lark-doc/SKILL.md | 86 +- .../references/genres/business-analysis.md | 30 + .../lark-doc/references/genres/data-report.md | 32 + skills/lark-doc/references/genres/email.md | 38 + .../references/genres/execution-plan.md | 27 + .../lark-doc/references/genres/formal-doc.md | 37 + .../references/genres/meeting-minutes.md | 24 + .../lark-doc/references/genres/memo-brief.md | 25 + .../references/genres/official-redhead.md | 73 + skills/lark-doc/references/genres/prd.md | 26 + skills/lark-doc/references/genres/proposal.md | 24 + .../references/genres/research-report.md | 32 + .../references/genres/retrospective.md | 25 + .../references/genres/route-consumer.md | 37 + .../references/genres/route-creative.md | 36 + .../references/genres/route-knowledge.md | 39 + .../references/genres/route-marketing.md | 40 + .../lark-doc/references/genres/route-media.md | 36 + .../references/genres/route-opinion.md | 38 + .../references/genres/route-personal-brand.md | 36 + .../references/genres/route-platform.md | 9 + .../references/genres/route-report.md | 10 + .../references/genres/route-workplace.md | 17 + .../references/genres/sop-tutorial.md | 41 + .../references/genres/technical-doc.md | 39 + skills/lark-doc/references/genres/wechat.md | 39 + .../references/genres/weekly-report.md | 24 + .../lark-doc/references/genres/white-paper.md | 32 + .../lark-doc/references/genres/xiaohongshu.md | 38 + .../references/lark-doc-create-workflow.md | 119 + skills/lark-doc/references/lark-doc-create.md | 70 +- skills/lark-doc/references/lark-doc-fetch.md | 167 +- skills/lark-doc/references/lark-doc-md.md | 6 +- skills/lark-doc/references/lark-doc-script.md | 77 + skills/lark-doc/references/lark-doc-update.md | 292 +- .../references/lark-doc-whiteboard.md | 14 +- .../lark-doc/references/lark-doc-word-stat.md | 93 - .../lark-doc-xml-extended-blocks.md | 29 +- skills/lark-doc/references/lark-doc-xml.md | 205 +- .../style/lark-doc-create-workflow.md | 47 - .../references/style/lark-doc-style.md | 68 - .../style/lark-doc-update-workflow.md | 48 - skills/lark-doc/scripts/doc_word_stat.py | 1243 -------- tests/cli_e2e/docs/coverage.md | 19 +- tests/cli_e2e/docs/docs_create_fetch_test.go | 18 + .../docs/docs_local_resources_dryrun_test.go | 173 + .../docs_local_resources_workflow_test.go | 471 +++ tests/cli_e2e/docs/docs_script_test.go | 572 ++++ tests/cli_e2e/docs/docs_update_dryrun_test.go | 31 + 84 files changed, 14785 insertions(+), 2202 deletions(-) create mode 100644 shortcuts/doc/docs_result_test.go create mode 100644 shortcuts/doc/docs_script.go create mode 100644 shortcuts/doc/docs_script_test.go create mode 100644 shortcuts/doc/internal/docxparse/block_catalog.go create mode 100644 shortcuts/doc/internal/docxparse/block_catalog_test.go create mode 100644 shortcuts/doc/internal/docxparse/errors.go create mode 100644 shortcuts/doc/internal/docxparse/markdown.go create mode 100644 shortcuts/doc/internal/docxparse/markdown_cjk.go create mode 100644 shortcuts/doc/internal/docxparse/markdown_extensions.go create mode 100644 shortcuts/doc/internal/docxparse/model.go create mode 100644 shortcuts/doc/internal/docxparse/parse_test.go create mode 100644 shortcuts/doc/internal/docxparse/profile.go create mode 100644 shortcuts/doc/internal/docxparse/wordcount.go create mode 100644 shortcuts/doc/internal/docxparse/xml.go create mode 100644 shortcuts/doc/internal/docxparse/xml_compat.go create mode 100644 shortcuts/doc/local_doc_resources.go create mode 100644 shortcuts/doc/local_doc_resources_test.go create mode 100644 skills/lark-doc/references/genres/business-analysis.md create mode 100644 skills/lark-doc/references/genres/data-report.md create mode 100644 skills/lark-doc/references/genres/email.md create mode 100644 skills/lark-doc/references/genres/execution-plan.md create mode 100644 skills/lark-doc/references/genres/formal-doc.md create mode 100644 skills/lark-doc/references/genres/meeting-minutes.md create mode 100644 skills/lark-doc/references/genres/memo-brief.md create mode 100644 skills/lark-doc/references/genres/official-redhead.md create mode 100644 skills/lark-doc/references/genres/prd.md create mode 100644 skills/lark-doc/references/genres/proposal.md create mode 100644 skills/lark-doc/references/genres/research-report.md create mode 100644 skills/lark-doc/references/genres/retrospective.md create mode 100644 skills/lark-doc/references/genres/route-consumer.md create mode 100644 skills/lark-doc/references/genres/route-creative.md create mode 100644 skills/lark-doc/references/genres/route-knowledge.md create mode 100644 skills/lark-doc/references/genres/route-marketing.md create mode 100644 skills/lark-doc/references/genres/route-media.md create mode 100644 skills/lark-doc/references/genres/route-opinion.md create mode 100644 skills/lark-doc/references/genres/route-personal-brand.md create mode 100644 skills/lark-doc/references/genres/route-platform.md create mode 100644 skills/lark-doc/references/genres/route-report.md create mode 100644 skills/lark-doc/references/genres/route-workplace.md create mode 100644 skills/lark-doc/references/genres/sop-tutorial.md create mode 100644 skills/lark-doc/references/genres/technical-doc.md create mode 100644 skills/lark-doc/references/genres/wechat.md create mode 100644 skills/lark-doc/references/genres/weekly-report.md create mode 100644 skills/lark-doc/references/genres/white-paper.md create mode 100644 skills/lark-doc/references/genres/xiaohongshu.md create mode 100644 skills/lark-doc/references/lark-doc-create-workflow.md create mode 100644 skills/lark-doc/references/lark-doc-script.md delete mode 100644 skills/lark-doc/references/lark-doc-word-stat.md delete mode 100644 skills/lark-doc/references/style/lark-doc-create-workflow.md delete mode 100644 skills/lark-doc/references/style/lark-doc-style.md delete mode 100644 skills/lark-doc/references/style/lark-doc-update-workflow.md delete mode 100755 skills/lark-doc/scripts/doc_word_stat.py create mode 100644 tests/cli_e2e/docs/docs_local_resources_dryrun_test.go create mode 100644 tests/cli_e2e/docs/docs_local_resources_workflow_test.go create mode 100644 tests/cli_e2e/docs/docs_script_test.go diff --git a/cmd/root_test.go b/cmd/root_test.go index 096c404de0..21836ea66d 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -624,7 +624,7 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi t.Errorf("Subtype = %q, want %q", problem.Subtype, errs.SubtypeUnknown) } - if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create" --no-wait --json`) { + if !strings.Contains(problem.Hint, `auth login --scope "docx:document:create docs:document.media:upload docx:document:write_only docx:document:readonly" --no-wait --json`) { t.Errorf("expected shortcut scoped recovery, got %q", problem.Hint) } } @@ -699,7 +699,12 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) { t.Errorf("rendered error lost need-authorization cause %v: %v", authErr.Cause, rendered) } - want := "existing hint\n" + recovery.UserAuthorization("docx:document:create").String() + want := "existing hint\n" + recovery.UserAuthorization( + "docx:document:create", + "docs:document.media:upload", + "docx:document:write_only", + "docx:document:readonly", + ).String() if problem.Hint != want { t.Errorf("expected appended hint %q, got %q", want, problem.Hint) } diff --git a/go.mod b/go.mod index 26497138b2..aaf164947a 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,9 @@ require ( github.com/spf13/pflag v1.0.9 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 + github.com/yuin/goldmark v1.7.16 github.com/zalando/go-keyring v0.2.8 + golang.org/x/image v0.24.0 golang.org/x/net v0.33.0 golang.org/x/sync v0.15.0 golang.org/x/sys v0.33.0 diff --git a/go.sum b/go.sum index 7e42f36199..d05f373bc1 100644 --- a/go.sum +++ b/go.sum @@ -131,6 +131,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -139,6 +141,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/internal/credential/credential_provider.go b/internal/credential/credential_provider.go index 8f20be11d9..59fe716916 100644 --- a/internal/credential/credential_provider.go +++ b/internal/credential/credential_provider.go @@ -139,6 +139,7 @@ type CredentialProvider struct { accountOnce sync.Once account *Account accountErr error + selectedMu sync.RWMutex selectedSource credentialSource hintOnce sync.Once @@ -191,7 +192,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er internal.UserOpenId = "" internal.UserName = "" } - p.selectedSource = source + p.setSelectedCredentialSource(source) return internal, nil } } @@ -200,7 +201,7 @@ func (p *CredentialProvider) doResolveAccount(ctx context.Context) (*Account, er if err != nil { return nil, err } - p.selectedSource = defaultTokenSource{resolver: p.defaultToken} + p.setSelectedCredentialSource(defaultTokenSource{resolver: p.defaultToken}) return acct, nil } return nil, core.NotConfiguredError() @@ -239,8 +240,8 @@ func (p *CredentialProvider) enrichUserInfo(ctx context.Context, acct *Account, } func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (credentialSource, error) { - if p.selectedSource != nil { - return p.selectedSource, nil + if source := p.getSelectedCredentialSource(); source != nil { + return source, nil } if p.defaultAcct == nil { return nil, nil @@ -248,10 +249,23 @@ func (p *CredentialProvider) selectedCredentialSource(ctx context.Context) (cred if _, err := p.ResolveAccount(ctx); err != nil { return nil, err } - if p.selectedSource == nil { + source := p.getSelectedCredentialSource() + if source == nil { return nil, fmt.Errorf("credential provider resolved an account without selecting a token source") } - return p.selectedSource, nil + return source, nil +} + +func (p *CredentialProvider) getSelectedCredentialSource() credentialSource { + p.selectedMu.RLock() + defer p.selectedMu.RUnlock() + return p.selectedSource +} + +func (p *CredentialProvider) setSelectedCredentialSource(source credentialSource) { + p.selectedMu.Lock() + defer p.selectedMu.Unlock() + p.selectedSource = source } func resolveTokenFromSource(ctx context.Context, source credentialSource, req TokenSpec) (*TokenResult, error) { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index e12224f0cd..6f7967a986 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -49,7 +49,7 @@ type RuntimeContext struct { Factory *cmdutil.Factory // injected by framework apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info - larkSDK *lark.Client // eagerly initialized in mountDeclarative + larkSDK *lark.Client // initialized for non-local shortcut execution stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call } @@ -195,7 +195,7 @@ func (ctx *RuntimeContext) AccessToken() (string, error) { return result.Token, nil } -// LarkSDK returns the eagerly-initialized Lark SDK client. +// LarkSDK returns the initialized Lark SDK client for non-local execution. func (ctx *RuntimeContext) LarkSDK() *lark.Client { return ctx.larkSDK } @@ -711,6 +711,13 @@ func (ctx *RuntimeContext) handleEmitterError(err error) { ctx.outputErrOnce.Do(func() { ctx.outputErr = err }) } +// OutputError returns the first deferred output failure captured by Out, +// OutRaw, or OutFormat. Commands that create local artifacts can use it to +// roll those artifacts back before returning the final command error. +func (ctx *RuntimeContext) OutputError() error { + return ctx.outputErr +} + func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer { if prettyFn == nil { return nil @@ -766,6 +773,21 @@ func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta return output.PartialFailure(output.ExitAPI) } +// OutPartialFailureRaw is like OutPartialFailure but disables HTML escaping +// for payloads that contain document markup in warning or result fields. +func (ctx *RuntimeContext) OutPartialFailureRaw(data interface{}, meta *output.Meta) error { + ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{ + Format: "", + Raw: true, + JQ: ctx.JqExpr, + Meta: meta, + })) + if ctx.outputErr != nil { + return ctx.outputErr + } + return output.PartialFailure(output.ExitAPI) +} + // OutFormat prints output based on --format flag. // "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. // When JqExpr is set, envelope filtering takes precedence over format. @@ -928,23 +950,29 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo return nil } } - as, err := resolveShortcutIdentity(cmd, f, s) - if err != nil { - return err + local := s.Local != nil && s.Local(cmd) + var ( + as core.Identity + config *core.CliConfig + err error + ) + if local { + as, err = resolveLocalShortcutIdentity(cmd, f, s) + config = &core.CliConfig{} + } else { + as, err = resolveShortcutIdentity(cmd, f, s) + if err == nil { + config, err = f.Config() + } + if err == nil { + err = checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))) + } } - - config, err := f.Config() if err != nil { return err } - // Identity info is now included in the JSON envelope; skip stderr printing. - // cmdutil.PrintIdentity(f.IOStreams.ErrOut, as, config, false) - if err := checkShortcutScopes(f, cmd.Context(), as, config, s.ScopesForIdentity(string(as))); err != nil { - return err - } - - rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly) + rctx, err := newRuntimeContext(cmd, f, s, config, as, botOnly, !local) if err != nil { return err } @@ -1006,6 +1034,24 @@ func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut return as, nil } +func resolveLocalShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { + f.IdentityAutoDetected = false + asFlag, _ := cmd.Flags().GetString("as") + as := core.AsBot + if !slices.Contains(s.AuthTypes, string(core.AsBot)) && len(s.AuthTypes) > 0 { + as = core.Identity(s.AuthTypes[0]) + } + if cmd.Flags().Changed("as") && core.Identity(asFlag) != core.AsAuto && strings.TrimSpace(asFlag) != "" { + as = core.Identity(asFlag) + } else { + f.IdentityAutoDetected = true + } + if err := f.CheckIdentity(as, s.AuthTypes); err != nil { + return "", err + } + return as, nil +} + func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identity, config *core.CliConfig, scopes []string) error { if len(scopes) == 0 { return nil @@ -1023,7 +1069,7 @@ func checkShortcutScopes(f *cmdutil.Factory, ctx context.Context, as core.Identi WithMissingScopes(missing...) } -func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly bool) (*RuntimeContext, error) { +func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, config *core.CliConfig, as core.Identity, botOnly, initializeLarkSDK bool) (*RuntimeContext, error) { ctx := cmd.Context() ctx = cmdutil.ContextWithShortcut(ctx, s.Service+":"+s.Command, uuid.New().String()) rctx := &RuntimeContext{ @@ -1040,11 +1086,13 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf }) rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo) - sdk, err := f.LarkClient() - if err != nil { - return nil, err + if initializeLarkSDK { + sdk, err := f.LarkClient() + if err != nil { + return nil, err + } + rctx.larkSDK = sdk } - rctx.larkSDK = sdk applyJSONShorthand(cmd, s) rctx.Format = rctx.Str("format") diff --git a/shortcuts/common/runner_error_presenter_test.go b/shortcuts/common/runner_error_presenter_test.go index d2839a3ef8..dca6455773 100644 --- a/shortcuts/common/runner_error_presenter_test.go +++ b/shortcuts/common/runner_error_presenter_test.go @@ -124,7 +124,7 @@ func TestNewRuntimeContextUsesEffectiveBotOnlyIdentityForDeclaredScopes(t *testi t.Fatal(err) } cmd := newTestShortcutCmd(shortcut, f) - runtime, err := newRuntimeContext(cmd, f, shortcut, config, core.AsUser, true) + runtime, err := newRuntimeContext(cmd, f, shortcut, config, core.AsUser, true, true) if err != nil { t.Fatalf("newRuntimeContext() error = %v", err) } diff --git a/shortcuts/common/runner_partial_failure_test.go b/shortcuts/common/runner_partial_failure_test.go index 3147abbe32..016bfae611 100644 --- a/shortcuts/common/runner_partial_failure_test.go +++ b/shortcuts/common/runner_partial_failure_test.go @@ -4,6 +4,7 @@ package common import ( + "bytes" "context" "encoding/json" "errors" @@ -61,3 +62,29 @@ func TestOutPartialFailure(t *testing.T) { t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String()) } } + +func TestOutPartialFailureRawPreservesDocumentMarkup(t *testing.T) { + cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"} + f, stdout, _, _ := cmdutil.TestFactory(t, cfg) + rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+script"}, cfg, f, core.AsUser) + + err := rt.OutPartialFailureRaw(map[string]interface{}{ + "warning": []string{`use `}, + }, nil) + var partialFailure *output.PartialFailureError + if !errors.As(err, &partialFailure) || partialFailure.Code != output.ExitAPI { + t.Fatalf("error = %T %v, want ExitAPI partial failure", err, err) + } + if bytes.Contains(stdout.Bytes(), []byte(`\u003cimg`)) || !bytes.Contains(stdout.Bytes(), []byte(`