@pippit-dev/cli 1.0.17 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -2
- package/checksums.txt +6 -6
- package/cmd/auth/auth.go +136 -142
- package/cmd/auth/auth_test.go +134 -0
- package/cmd/canvas/canvas.go +235 -0
- package/cmd/canvas/canvas_test.go +126 -0
- package/cmd/root.go +29 -4
- package/cmd/root_test.go +60 -0
- package/cmd/short_drama_test.go +4 -7
- package/cmd/update/update.go +34 -5
- package/cmd/update/update_test.go +84 -0
- package/internal/auth/auth_test.go +813 -0
- package/internal/auth/browser_darwin.go +14 -0
- package/internal/auth/browser_env.go +35 -0
- package/internal/auth/browser_linux.go +14 -0
- package/internal/auth/browser_windows.go +14 -0
- package/internal/auth/identity.go +89 -0
- package/internal/auth/loopback.go +324 -0
- package/internal/auth/manager.go +338 -144
- package/internal/auth/store.go +303 -0
- package/internal/auth/store_file_unix.go +176 -0
- package/internal/auth/store_file_windows.go +11 -0
- package/internal/auth/types.go +72 -0
- package/internal/canvas/allocate.go +70 -0
- package/internal/canvas/apply.go +203 -0
- package/internal/canvas/canvas_test.go +446 -0
- package/internal/canvas/create.go +380 -0
- package/internal/canvas/get.go +156 -0
- package/internal/canvas/types.go +68 -0
- package/internal/canvas/upload.go +250 -0
- package/internal/common/access_key.go +42 -6
- package/internal/common/access_key_test.go +113 -0
- package/internal/common/client.go +119 -21
- package/internal/common/client_test.go +213 -0
- package/internal/common/runner.go +15 -0
- package/internal/config/config.go +0 -20
- package/internal/config/config_test.go +0 -18
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +4 -4
- package/skills/xyq-nest-skill/SKILL.md +11 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
var decimalIDPattern = regexp.MustCompile(`^[1-9][0-9]*$`)
|
|
14
|
+
|
|
15
|
+
type ApplyOptions struct {
|
|
16
|
+
ProjectID string
|
|
17
|
+
Request ApplyRequest
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ApplyRequest struct {
|
|
21
|
+
BatchID string `json:"batch_id"`
|
|
22
|
+
ClientID string `json:"client_id"`
|
|
23
|
+
RootPippitAssetID string `json:"root_pippit_asset_id,omitempty"`
|
|
24
|
+
Transactions []PatchTransaction `json:"transactions"`
|
|
25
|
+
Base map[string]any `json:"Base"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type PatchTransaction struct {
|
|
29
|
+
TransactionID string `json:"transaction_id"`
|
|
30
|
+
Intent string `json:"intent,omitempty"`
|
|
31
|
+
MergeKey string `json:"merge_key,omitempty"`
|
|
32
|
+
Attempt *int32 `json:"attempt,omitempty"`
|
|
33
|
+
EnqueuedAt *int64 `json:"enqueued_at,omitempty"`
|
|
34
|
+
Patches []PatchEntry `json:"patches"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PatchEntry struct {
|
|
38
|
+
AssetID string `json:"asset_id"`
|
|
39
|
+
BaseAssetVersion *int64 `json:"base_asset_version,omitempty"`
|
|
40
|
+
AssetSourceType *int32 `json:"asset_source_type,omitempty"`
|
|
41
|
+
Op string `json:"op"`
|
|
42
|
+
Path string `json:"path"`
|
|
43
|
+
Value json.RawMessage `json:"value,omitempty"`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type ApplyResult struct {
|
|
47
|
+
BatchID string `json:"batch_id"`
|
|
48
|
+
Results []PatchTransactionResult `json:"results"`
|
|
49
|
+
LogID string `json:"log_id,omitempty"`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type PatchTransactionResult struct {
|
|
53
|
+
TransactionID string `json:"transaction_id"`
|
|
54
|
+
Status string `json:"status"`
|
|
55
|
+
AssetVersions map[string]int64 `json:"asset_versions,omitempty"`
|
|
56
|
+
BlockedByTransaction string `json:"blocked_by,omitempty"`
|
|
57
|
+
Error string `json:"error,omitempty"`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type applyData struct {
|
|
61
|
+
Results []PatchTransactionResult `json:"results"`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func Apply(ctx context.Context, opts ApplyOptions, runner *common.Runner) (*ApplyResult, error) {
|
|
65
|
+
client, err := runnerClient(runner, "canvas apply")
|
|
66
|
+
if err != nil {
|
|
67
|
+
return nil, err
|
|
68
|
+
}
|
|
69
|
+
projectID := strings.TrimSpace(opts.ProjectID)
|
|
70
|
+
if projectID != "" && !decimalIDPattern.MatchString(projectID) {
|
|
71
|
+
return nil, fmt.Errorf("canvas apply project_id must be a positive decimal JSON string")
|
|
72
|
+
}
|
|
73
|
+
request := opts.Request
|
|
74
|
+
request.Base = base()
|
|
75
|
+
if err := validateApplyRequest(&request); err != nil {
|
|
76
|
+
return nil, err
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
var envelope responseEnvelope
|
|
80
|
+
if err := client.SendRequest(ctx, pathWithProjectID(ApplyPath, projectID), request, &envelope); err != nil {
|
|
81
|
+
return nil, fmt.Errorf("canvas apply request failed; outcome may be ambiguous, do not replay blindly: %w", err)
|
|
82
|
+
}
|
|
83
|
+
if err := envelope.validate("canvas apply"); err != nil {
|
|
84
|
+
return nil, err
|
|
85
|
+
}
|
|
86
|
+
var data applyData
|
|
87
|
+
if err := json.Unmarshal(envelope.Data, &data); err != nil {
|
|
88
|
+
return nil, common.NewLogIDError(
|
|
89
|
+
fmt.Sprintf("canvas apply returned invalid data: %v; query affected assets before retrying because outcome cannot be confirmed", err),
|
|
90
|
+
envelope.LogID,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
ordered, err := validateApplyResults(request.Transactions, data.Results)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return nil, common.NewLogIDError(
|
|
96
|
+
fmt.Sprintf("%s; query affected assets before retrying because outcome cannot be confirmed", err),
|
|
97
|
+
envelope.LogID,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return &ApplyResult{
|
|
101
|
+
BatchID: request.BatchID,
|
|
102
|
+
Results: ordered,
|
|
103
|
+
LogID: strings.TrimSpace(envelope.LogID),
|
|
104
|
+
}, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
func validateApplyRequest(request *ApplyRequest) error {
|
|
108
|
+
if request == nil {
|
|
109
|
+
return fmt.Errorf("canvas apply request is required")
|
|
110
|
+
}
|
|
111
|
+
request.BatchID = strings.TrimSpace(request.BatchID)
|
|
112
|
+
request.ClientID = strings.TrimSpace(request.ClientID)
|
|
113
|
+
request.RootPippitAssetID = strings.TrimSpace(request.RootPippitAssetID)
|
|
114
|
+
if request.BatchID == "" {
|
|
115
|
+
return fmt.Errorf("canvas apply batch_id is required")
|
|
116
|
+
}
|
|
117
|
+
if request.ClientID == "" {
|
|
118
|
+
return fmt.Errorf("canvas apply client_id is required")
|
|
119
|
+
}
|
|
120
|
+
if len(request.Transactions) != 1 {
|
|
121
|
+
return fmt.Errorf("canvas apply requires exactly one transaction in this beta; put related patches in that transaction")
|
|
122
|
+
}
|
|
123
|
+
transactionIDs := make(map[string]struct{}, len(request.Transactions))
|
|
124
|
+
for txIndex := range request.Transactions {
|
|
125
|
+
tx := &request.Transactions[txIndex]
|
|
126
|
+
tx.TransactionID = strings.TrimSpace(tx.TransactionID)
|
|
127
|
+
if tx.TransactionID == "" {
|
|
128
|
+
return fmt.Errorf("canvas apply transactions[%d].transaction_id is required", txIndex)
|
|
129
|
+
}
|
|
130
|
+
if _, duplicate := transactionIDs[tx.TransactionID]; duplicate {
|
|
131
|
+
return fmt.Errorf("canvas apply transaction_id %q is duplicated", tx.TransactionID)
|
|
132
|
+
}
|
|
133
|
+
transactionIDs[tx.TransactionID] = struct{}{}
|
|
134
|
+
if len(tx.Patches) == 0 {
|
|
135
|
+
return fmt.Errorf("canvas apply transaction %q has no patches", tx.TransactionID)
|
|
136
|
+
}
|
|
137
|
+
for patchIndex := range tx.Patches {
|
|
138
|
+
patch := &tx.Patches[patchIndex]
|
|
139
|
+
patch.AssetID = strings.TrimSpace(patch.AssetID)
|
|
140
|
+
patch.Op = strings.ToLower(strings.TrimSpace(patch.Op))
|
|
141
|
+
if patch.AssetID == "" {
|
|
142
|
+
return fmt.Errorf("canvas apply transaction %q patch[%d].asset_id is required", tx.TransactionID, patchIndex)
|
|
143
|
+
}
|
|
144
|
+
switch patch.Op {
|
|
145
|
+
case "add", "replace":
|
|
146
|
+
if len(patch.Value) == 0 || !json.Valid(patch.Value) {
|
|
147
|
+
return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex)
|
|
148
|
+
}
|
|
149
|
+
case "remove":
|
|
150
|
+
if len(patch.Value) != 0 && !json.Valid(patch.Value) {
|
|
151
|
+
return fmt.Errorf("canvas apply transaction %q patch[%d].value must be valid JSON", tx.TransactionID, patchIndex)
|
|
152
|
+
}
|
|
153
|
+
default:
|
|
154
|
+
return fmt.Errorf("canvas apply transaction %q patch[%d].op must be add, replace, or remove", tx.TransactionID, patchIndex)
|
|
155
|
+
}
|
|
156
|
+
if patch.BaseAssetVersion != nil && *patch.BaseAssetVersion < 0 {
|
|
157
|
+
return fmt.Errorf("canvas apply transaction %q patch[%d].base_asset_version must not be negative", tx.TransactionID, patchIndex)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return nil
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
func validateApplyResults(expected []PatchTransaction, actual []PatchTransactionResult) ([]PatchTransactionResult, error) {
|
|
165
|
+
byID := make(map[string]PatchTransactionResult, len(actual))
|
|
166
|
+
for _, result := range actual {
|
|
167
|
+
result.TransactionID = strings.TrimSpace(result.TransactionID)
|
|
168
|
+
if result.TransactionID == "" {
|
|
169
|
+
return nil, fmt.Errorf("canvas apply returned a result without transaction_id")
|
|
170
|
+
}
|
|
171
|
+
if _, duplicate := byID[result.TransactionID]; duplicate {
|
|
172
|
+
return nil, fmt.Errorf("canvas apply returned duplicate result for transaction %q", result.TransactionID)
|
|
173
|
+
}
|
|
174
|
+
byID[result.TransactionID] = result
|
|
175
|
+
}
|
|
176
|
+
if len(byID) != len(expected) {
|
|
177
|
+
return nil, fmt.Errorf("canvas apply returned %d transaction results, want %d", len(byID), len(expected))
|
|
178
|
+
}
|
|
179
|
+
ordered := make([]PatchTransactionResult, 0, len(expected))
|
|
180
|
+
for _, transaction := range expected {
|
|
181
|
+
result, ok := byID[transaction.TransactionID]
|
|
182
|
+
if !ok {
|
|
183
|
+
return nil, fmt.Errorf("canvas apply omitted transaction result %q", transaction.TransactionID)
|
|
184
|
+
}
|
|
185
|
+
if strings.ToLower(strings.TrimSpace(result.Status)) != "ack" {
|
|
186
|
+
return nil, fmt.Errorf(
|
|
187
|
+
"canvas apply transaction %q was not acknowledged: status=%q blocked_by=%q error=%q",
|
|
188
|
+
transaction.TransactionID, result.Status, result.BlockedByTransaction, result.Error,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
for _, patch := range transaction.Patches {
|
|
192
|
+
version, ok := result.AssetVersions[patch.AssetID]
|
|
193
|
+
if !ok {
|
|
194
|
+
return nil, fmt.Errorf("canvas apply transaction %q omitted version for asset %q", transaction.TransactionID, patch.AssetID)
|
|
195
|
+
}
|
|
196
|
+
if version < 0 {
|
|
197
|
+
return nil, fmt.Errorf("canvas apply transaction %q returned negative version for asset %q", transaction.TransactionID, patch.AssetID)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
ordered = append(ordered, result)
|
|
201
|
+
}
|
|
202
|
+
return ordered, nil
|
|
203
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
package canvas
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"strings"
|
|
11
|
+
"testing"
|
|
12
|
+
"time"
|
|
13
|
+
|
|
14
|
+
"github.com/Pippit-dev/pippit-cli/internal/common"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
type fakeClient struct {
|
|
18
|
+
send func(context.Context, string, any, any) error
|
|
19
|
+
multipart func(context.Context, string, map[string]string, common.MultipartFile, any) error
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func (f *fakeClient) SendRequest(ctx context.Context, path string, body any, out any) error {
|
|
23
|
+
if f.send == nil {
|
|
24
|
+
return fmt.Errorf("unexpected request to %s", path)
|
|
25
|
+
}
|
|
26
|
+
return f.send(ctx, path, body, out)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func (f *fakeClient) SendRequestWithHeaders(ctx context.Context, path string, body any, _ map[string]string, out any) error {
|
|
30
|
+
return f.SendRequest(ctx, path, body, out)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (f *fakeClient) SendMultipartRequest(
|
|
34
|
+
ctx context.Context,
|
|
35
|
+
path string,
|
|
36
|
+
fields map[string]string,
|
|
37
|
+
file common.MultipartFile,
|
|
38
|
+
out any,
|
|
39
|
+
) error {
|
|
40
|
+
if f.multipart == nil {
|
|
41
|
+
return fmt.Errorf("unexpected multipart request to %s", path)
|
|
42
|
+
}
|
|
43
|
+
return f.multipart(ctx, path, fields, file, out)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func runnerWithClient(client common.Client) *common.Runner {
|
|
47
|
+
return &common.Runner{Client: client}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func decodeInto(out any, payload string) error {
|
|
51
|
+
return json.Unmarshal([]byte(payload), out)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func requestJSON(t *testing.T, body any) map[string]any {
|
|
55
|
+
t.Helper()
|
|
56
|
+
payload, err := json.Marshal(body)
|
|
57
|
+
if err != nil {
|
|
58
|
+
t.Fatalf("json.Marshal() error = %v", err)
|
|
59
|
+
}
|
|
60
|
+
var result map[string]any
|
|
61
|
+
if err := json.Unmarshal(payload, &result); err != nil {
|
|
62
|
+
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func TestCreateReturnsRecoverableOperationIDs(t *testing.T) {
|
|
68
|
+
client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
|
|
69
|
+
if path != CreatePath {
|
|
70
|
+
t.Fatalf("path = %q, want %q", path, CreatePath)
|
|
71
|
+
}
|
|
72
|
+
request := requestJSON(t, body)
|
|
73
|
+
if request["surface"] != SurfaceNovel || request["request_id"] != "request-1" {
|
|
74
|
+
t.Fatalf("request = %#v, want novel request", request)
|
|
75
|
+
}
|
|
76
|
+
if baseValue, ok := request["Base"].(map[string]any); !ok || len(baseValue) != 0 {
|
|
77
|
+
t.Fatalf("Base = %#v, want empty object", request["Base"])
|
|
78
|
+
}
|
|
79
|
+
if _, exists := request["team_id"]; exists {
|
|
80
|
+
t.Fatalf("request = %#v, must not contain team context", request)
|
|
81
|
+
}
|
|
82
|
+
return decodeInto(out, `{"ret":"0","log_id":"log-create","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
83
|
+
}}
|
|
84
|
+
|
|
85
|
+
result, err := Create(context.Background(), CreateOptions{Title: "A", RequestID: "request-1"}, runnerWithClient(client))
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatalf("Create() error = %v", err)
|
|
88
|
+
}
|
|
89
|
+
if result.RequestID != "request-1" || result.State != StateCreating || result.ProjectID != "100" || result.CanvasAssetID != "200" {
|
|
90
|
+
t.Fatalf("Create() = %#v, want recoverable creating IDs", result)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func TestCreateWaitWithInlineOverviewStillRequiresQueryableRoot(t *testing.T) {
|
|
95
|
+
queryCalls := 0
|
|
96
|
+
client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
|
|
97
|
+
switch path {
|
|
98
|
+
case CreatePath:
|
|
99
|
+
return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","overview_pippit_asset_id":"300","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
100
|
+
case QueryPath:
|
|
101
|
+
queryCalls++
|
|
102
|
+
return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`)
|
|
103
|
+
default:
|
|
104
|
+
return fmt.Errorf("unexpected path %s", path)
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
result, err := Create(context.Background(), CreateOptions{RequestID: "request-1", Wait: true}, runnerWithClient(client))
|
|
108
|
+
if err != nil {
|
|
109
|
+
t.Fatalf("Create() error = %v", err)
|
|
110
|
+
}
|
|
111
|
+
if queryCalls != 1 || result.State != StateCreating || !strings.Contains(result.Warning, "root asset is not queryable yet") {
|
|
112
|
+
t.Fatalf("Create() = %#v, query calls=%d, want visibility-gated ready", result, queryCalls)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func TestCreateTransportFailureExplainsAmbiguousOutcome(t *testing.T) {
|
|
117
|
+
client := &fakeClient{send: func(context.Context, string, any, any) error {
|
|
118
|
+
return errors.New("timeout")
|
|
119
|
+
}}
|
|
120
|
+
_, err := Create(context.Background(), CreateOptions{RequestID: "request-1"}, runnerWithClient(client))
|
|
121
|
+
if err == nil || !strings.Contains(err.Error(), "outcome may be ambiguous, do not retry blindly") {
|
|
122
|
+
t.Fatalf("Create() error = %v, want ambiguous outcome guidance", err)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func TestCreateWaitsForMachineArtifactWithoutV2(t *testing.T) {
|
|
127
|
+
getThreadCalls := 0
|
|
128
|
+
client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
|
|
129
|
+
switch path {
|
|
130
|
+
case CreatePath:
|
|
131
|
+
return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
132
|
+
case "/api/biz/v1/skill/get_thread":
|
|
133
|
+
getThreadCalls++
|
|
134
|
+
request := requestJSON(t, body)
|
|
135
|
+
if _, exists := request["version"]; exists {
|
|
136
|
+
t.Fatalf("get_thread request = %#v, must not request v2 readable text", request)
|
|
137
|
+
}
|
|
138
|
+
return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}] }]}}}`)
|
|
139
|
+
case QueryPath:
|
|
140
|
+
return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":"200"}]}}`)
|
|
141
|
+
default:
|
|
142
|
+
return fmt.Errorf("unexpected path %s", path)
|
|
143
|
+
}
|
|
144
|
+
}}
|
|
145
|
+
|
|
146
|
+
result, err := Create(context.Background(), CreateOptions{
|
|
147
|
+
RequestID: "request-1",
|
|
148
|
+
Wait: true,
|
|
149
|
+
PollInterval: time.Millisecond,
|
|
150
|
+
WaitTimeout: time.Second,
|
|
151
|
+
}, runnerWithClient(client))
|
|
152
|
+
if err != nil {
|
|
153
|
+
t.Fatalf("Create() error = %v", err)
|
|
154
|
+
}
|
|
155
|
+
if getThreadCalls != 1 || result.State != StateReady || result.OverviewPippitAssetID != "300" {
|
|
156
|
+
t.Fatalf("Create() = %#v, calls=%d, want ready artifact", result, getThreadCalls)
|
|
157
|
+
}
|
|
158
|
+
if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") {
|
|
159
|
+
t.Fatalf("WebURL = %q, want canonical overview URL", result.WebURL)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
func TestCreateKeepsCreatingWhenRootAssetIsNotQueryable(t *testing.T) {
|
|
164
|
+
client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
|
|
165
|
+
switch path {
|
|
166
|
+
case CreatePath:
|
|
167
|
+
return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
168
|
+
case "/api/biz/v1/skill/get_thread":
|
|
169
|
+
return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":3,"content":[{"sub_type":"biz/x_data_novel_script_overview","data":"{\"pippit_asset_id\":\"300\",\"canvas_asset_id\":\"200\"}"}]}]}}}`)
|
|
170
|
+
case QueryPath:
|
|
171
|
+
return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[]}}`)
|
|
172
|
+
default:
|
|
173
|
+
return fmt.Errorf("unexpected path %s", path)
|
|
174
|
+
}
|
|
175
|
+
}}
|
|
176
|
+
result, err := Create(context.Background(), CreateOptions{
|
|
177
|
+
RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second,
|
|
178
|
+
}, runnerWithClient(client))
|
|
179
|
+
if err != nil {
|
|
180
|
+
t.Fatalf("Create() error = %v, want accepted processing result", err)
|
|
181
|
+
}
|
|
182
|
+
if result.State != StateCreating || result.ProjectID != "100" || result.OverviewPippitAssetID != "300" || !strings.Contains(result.Warning, "root asset is not queryable yet") {
|
|
183
|
+
t.Fatalf("Create() = %#v, want creating state with query warning", result)
|
|
184
|
+
}
|
|
185
|
+
if strings.Contains(result.WebURL, "canvasId=") || !strings.Contains(result.WebURL, "overviewPippitAssetId=300") {
|
|
186
|
+
t.Fatalf("WebURL = %q, want preserved canonical overview URL", result.WebURL)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func TestCreateWaitTimeoutPreservesAcceptedIDs(t *testing.T) {
|
|
191
|
+
client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
|
|
192
|
+
switch path {
|
|
193
|
+
case CreatePath:
|
|
194
|
+
return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
195
|
+
case "/api/biz/v1/skill/get_thread":
|
|
196
|
+
return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":1}]}}}`)
|
|
197
|
+
default:
|
|
198
|
+
return fmt.Errorf("unexpected path %s", path)
|
|
199
|
+
}
|
|
200
|
+
}}
|
|
201
|
+
|
|
202
|
+
result, err := Create(context.Background(), CreateOptions{
|
|
203
|
+
RequestID: "request-1",
|
|
204
|
+
Wait: true,
|
|
205
|
+
PollInterval: 50 * time.Millisecond,
|
|
206
|
+
WaitTimeout: time.Millisecond,
|
|
207
|
+
}, runnerWithClient(client))
|
|
208
|
+
if err != nil {
|
|
209
|
+
t.Fatalf("Create() error = %v, want accepted result with warning", err)
|
|
210
|
+
}
|
|
211
|
+
if result == nil || result.ProjectID != "100" || result.ThreadID != "thread-1" || result.RunID != "run-1" || result.CanvasAssetID != "200" {
|
|
212
|
+
t.Fatalf("Create() = %#v, want all accepted IDs", result)
|
|
213
|
+
}
|
|
214
|
+
if result.State != StateCreating || !strings.Contains(result.Warning, "was not ready") {
|
|
215
|
+
t.Fatalf("Create() = %#v, want creating state with timeout warning", result)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func TestCreateTerminalFailureReturnsAcceptedIDsAndError(t *testing.T) {
|
|
220
|
+
client := &fakeClient{send: func(_ context.Context, path string, _ any, out any) error {
|
|
221
|
+
if path == CreatePath {
|
|
222
|
+
return decodeInto(out, `{"ret":"0","data":{"state":"creating","project_id":"100","thread_id":"thread-1","run_id":"run-1","canvas_asset_id":"200","web_url":"https://xyq.jianying.com/novel/detail/canvas?projectId=100&canvasId=200"}}`)
|
|
223
|
+
}
|
|
224
|
+
return decodeInto(out, `{"ret":"0","data":{"thread":{"run_list":[{"run_id":"run-1","state":4}]}}}`)
|
|
225
|
+
}}
|
|
226
|
+
result, err := Create(context.Background(), CreateOptions{
|
|
227
|
+
RequestID: "request-1", Wait: true, PollInterval: time.Millisecond, WaitTimeout: time.Second,
|
|
228
|
+
}, runnerWithClient(client))
|
|
229
|
+
if result == nil || result.ProjectID != "100" || result.State != "failed" {
|
|
230
|
+
t.Fatalf("Create() result = %#v, want failed result with accepted IDs", result)
|
|
231
|
+
}
|
|
232
|
+
if err == nil || !strings.Contains(err.Error(), "accepted canvas IDs") || !strings.Contains(err.Error(), "do not create again blindly") {
|
|
233
|
+
t.Fatalf("Create() error = %v, want accepted ID guidance", err)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func TestGetRequiresEveryRequestedStringID(t *testing.T) {
|
|
238
|
+
client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
|
|
239
|
+
if path != QueryPath {
|
|
240
|
+
t.Fatalf("path = %q, want query", path)
|
|
241
|
+
}
|
|
242
|
+
return decodeInto(out, `{"ret":"0","log_id":"log-query","data":{"Assets":[{"PippitAssetID":"2"},{"PippitAssetID":"1"}]}}`)
|
|
243
|
+
}}
|
|
244
|
+
result, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1", "2"}}, runnerWithClient(client))
|
|
245
|
+
if err != nil {
|
|
246
|
+
t.Fatalf("Get() error = %v", err)
|
|
247
|
+
}
|
|
248
|
+
firstID, _ := assetIDFromRaw(result.Assets[0])
|
|
249
|
+
if firstID != "1" || result.LogID != "log-query" {
|
|
250
|
+
t.Fatalf("Get() = %#v, want request order and log ID", result)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
missingClient := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
254
|
+
return decodeInto(out, `{"ret":"0","log_id":"missing-log","data":{"Assets":[]}}`)
|
|
255
|
+
}}
|
|
256
|
+
_, err = Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(missingClient))
|
|
257
|
+
if err == nil || !strings.Contains(err.Error(), "did not return requested assets: 1") || !strings.Contains(err.Error(), "missing-log") {
|
|
258
|
+
t.Fatalf("Get() error = %v, want strict missing asset error", err)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func TestGetRejectsNumericAssetIDInResponse(t *testing.T) {
|
|
263
|
+
client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
264
|
+
return decodeInto(out, `{"ret":"0","data":{"Assets":[{"PippitAssetID":123}]}}`)
|
|
265
|
+
}}
|
|
266
|
+
_, err := Get(context.Background(), GetOptions{AssetIDs: []string{"123"}}, runnerWithClient(client))
|
|
267
|
+
if err == nil || !strings.Contains(err.Error(), "must be a JSON string") {
|
|
268
|
+
t.Fatalf("Get() error = %v, want string ID validation", err)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func TestAllocateRequiresExactUniqueStringIDs(t *testing.T) {
|
|
273
|
+
client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
|
|
274
|
+
if path != AllocatePath {
|
|
275
|
+
t.Fatalf("path = %q, want allocate", path)
|
|
276
|
+
}
|
|
277
|
+
request := requestJSON(t, body)
|
|
278
|
+
if request["count"] != float64(2) {
|
|
279
|
+
t.Fatalf("count = %#v, want 2", request["count"])
|
|
280
|
+
}
|
|
281
|
+
return decodeInto(out, `{"ret":"0","data":{"ids":["10","11"]}}`)
|
|
282
|
+
}}
|
|
283
|
+
result, err := Allocate(context.Background(), 2, runnerWithClient(client))
|
|
284
|
+
if err != nil || strings.Join(result.AssetIDs, ",") != "10,11" {
|
|
285
|
+
t.Fatalf("Allocate() = (%#v, %v), want two IDs", result, err)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
duplicate := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
289
|
+
return decodeInto(out, `{"ret":"0","data":{"ids":["10","10"]}}`)
|
|
290
|
+
}}
|
|
291
|
+
_, err = Allocate(context.Background(), 2, runnerWithClient(duplicate))
|
|
292
|
+
if err == nil || !strings.Contains(err.Error(), "duplicate id") {
|
|
293
|
+
t.Fatalf("Allocate() error = %v, want duplicate rejection", err)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func TestApplyRequiresAcknowledgementAndEveryAssetVersion(t *testing.T) {
|
|
298
|
+
version := int64(0)
|
|
299
|
+
request := ApplyRequest{
|
|
300
|
+
BatchID: "batch-1",
|
|
301
|
+
ClientID: "client-1",
|
|
302
|
+
Transactions: []PatchTransaction{{
|
|
303
|
+
TransactionID: "tx-1",
|
|
304
|
+
Patches: []PatchEntry{{AssetID: "asset-1", BaseAssetVersion: &version, Op: "add", Path: "", Value: json.RawMessage(`{"nodes":[]}`)}},
|
|
305
|
+
}},
|
|
306
|
+
}
|
|
307
|
+
client := &fakeClient{send: func(_ context.Context, path string, body any, out any) error {
|
|
308
|
+
if path != ApplyPath+"?project_id=123" {
|
|
309
|
+
t.Fatalf("path = %q, want project query", path)
|
|
310
|
+
}
|
|
311
|
+
payload := requestJSON(t, body)
|
|
312
|
+
if baseValue, ok := payload["Base"].(map[string]any); !ok || len(baseValue) != 0 {
|
|
313
|
+
t.Fatalf("Base = %#v, want empty object", payload["Base"])
|
|
314
|
+
}
|
|
315
|
+
return decodeInto(out, `{"ret":"0","log_id":"log-apply","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{"asset-1":1}}]}}`)
|
|
316
|
+
}}
|
|
317
|
+
result, err := Apply(context.Background(), ApplyOptions{ProjectID: "123", Request: request}, runnerWithClient(client))
|
|
318
|
+
if err != nil || result.Results[0].AssetVersions["asset-1"] != 1 {
|
|
319
|
+
t.Fatalf("Apply() = (%#v, %v), want ack", result, err)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
rejected := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
323
|
+
return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"blocked","blocked_by":"tx-0"}]}}`)
|
|
324
|
+
}}
|
|
325
|
+
_, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(rejected))
|
|
326
|
+
if err == nil || !strings.Contains(err.Error(), "was not acknowledged") {
|
|
327
|
+
t.Fatalf("Apply() error = %v, want status rejection", err)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
missingVersion := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
331
|
+
return decodeInto(out, `{"ret":"0","data":{"results":[{"transaction_id":"tx-1","status":"ack","asset_versions":{}}]}}`)
|
|
332
|
+
}}
|
|
333
|
+
_, err = Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(missingVersion))
|
|
334
|
+
if err == nil || !strings.Contains(err.Error(), "omitted version") {
|
|
335
|
+
t.Fatalf("Apply() error = %v, want missing version rejection", err)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
func TestApplyBetaRejectsMultipleTransactionsBeforeRequest(t *testing.T) {
|
|
340
|
+
requestCalls := 0
|
|
341
|
+
client := &fakeClient{send: func(context.Context, string, any, any) error {
|
|
342
|
+
requestCalls++
|
|
343
|
+
return nil
|
|
344
|
+
}}
|
|
345
|
+
request := ApplyRequest{
|
|
346
|
+
BatchID: "batch-1", ClientID: "client-1",
|
|
347
|
+
Transactions: []PatchTransaction{
|
|
348
|
+
{TransactionID: "tx-1", Patches: []PatchEntry{{AssetID: "asset-1", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
|
|
349
|
+
{TransactionID: "tx-2", Patches: []PatchEntry{{AssetID: "asset-2", Op: "add", Path: "", Value: json.RawMessage(`{}`)}}},
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
_, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
|
|
353
|
+
if err == nil || !strings.Contains(err.Error(), "exactly one transaction") {
|
|
354
|
+
t.Fatalf("Apply() error = %v, want single-transaction beta guard", err)
|
|
355
|
+
}
|
|
356
|
+
if requestCalls != 0 {
|
|
357
|
+
t.Fatalf("request calls = %d, want validation before side effects", requestCalls)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
func TestApplyTransportFailureWarnsAgainstBlindReplay(t *testing.T) {
|
|
362
|
+
request := ApplyRequest{
|
|
363
|
+
BatchID: "batch-1", ClientID: "client-1",
|
|
364
|
+
Transactions: []PatchTransaction{{TransactionID: "tx-1", Patches: []PatchEntry{{
|
|
365
|
+
AssetID: "asset-1", Op: "replace", Path: "", Value: json.RawMessage(`{}`),
|
|
366
|
+
}}}},
|
|
367
|
+
}
|
|
368
|
+
client := &fakeClient{send: func(context.Context, string, any, any) error { return errors.New("timeout") }}
|
|
369
|
+
_, err := Apply(context.Background(), ApplyOptions{Request: request}, runnerWithClient(client))
|
|
370
|
+
if err == nil || !strings.Contains(err.Error(), "do not replay blindly") {
|
|
371
|
+
t.Fatalf("Apply() error = %v, want replay warning", err)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
func TestUploadWaitsForQueryableAssetWithoutRequiringCover(t *testing.T) {
|
|
376
|
+
path := filepath.Join(t.TempDir(), "clip.mp4")
|
|
377
|
+
if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
|
|
378
|
+
t.Fatalf("WriteFile() error = %v", err)
|
|
379
|
+
}
|
|
380
|
+
client := &fakeClient{
|
|
381
|
+
multipart: func(_ context.Context, gotPath string, fields map[string]string, file common.MultipartFile, out any) error {
|
|
382
|
+
if gotPath != UploadPath || len(fields) != 0 {
|
|
383
|
+
t.Fatalf("multipart = (%q, %#v), want upload without auth fields", gotPath, fields)
|
|
384
|
+
}
|
|
385
|
+
if file.FieldName != "file" || file.ContentType != "video/mp4" {
|
|
386
|
+
t.Fatalf("file = %#v, want video multipart", file)
|
|
387
|
+
}
|
|
388
|
+
return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
|
|
389
|
+
},
|
|
390
|
+
send: func(_ context.Context, path string, _ any, out any) error {
|
|
391
|
+
if path != QueryPath {
|
|
392
|
+
t.Fatalf("path = %q, want query", path)
|
|
393
|
+
}
|
|
394
|
+
return decodeInto(out, `{"ret":"0","log_id":"query-log","data":{"Assets":[{"PippitAssetID":"asset-1","SourceID":"workspace-1","Video":{"DownloadUrl":"https://example.test/clip.mp4","VID":"vid-1"}}]}}`)
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
result, err := Upload(context.Background(), UploadOptions{
|
|
398
|
+
Path: path, PollInterval: time.Millisecond, WaitTimeout: time.Second,
|
|
399
|
+
}, runnerWithClient(client))
|
|
400
|
+
if err != nil {
|
|
401
|
+
t.Fatalf("Upload() error = %v", err)
|
|
402
|
+
}
|
|
403
|
+
if result.State != StateReady || result.AssetID != "workspace-1" || result.PippitAssetID != "asset-1" || result.PollAttempts != 1 {
|
|
404
|
+
t.Fatalf("Upload() = %#v, want ready identifiers", result)
|
|
405
|
+
}
|
|
406
|
+
if result.Locator.DownloadURL == "" || result.Locator.CoverURL != "" {
|
|
407
|
+
t.Fatalf("Locator = %#v, want playable locator without cover requirement", result.Locator)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
func TestUploadWaitTimeoutPreservesDurableAssetID(t *testing.T) {
|
|
412
|
+
path := filepath.Join(t.TempDir(), "clip.mp4")
|
|
413
|
+
if err := os.WriteFile(path, []byte("video"), 0o600); err != nil {
|
|
414
|
+
t.Fatalf("WriteFile() error = %v", err)
|
|
415
|
+
}
|
|
416
|
+
client := &fakeClient{
|
|
417
|
+
multipart: func(_ context.Context, _ string, _ map[string]string, _ common.MultipartFile, out any) error {
|
|
418
|
+
return decodeInto(out, `{"ret":"0","log_id":"upload-log","data":{"asset_id":"workspace-1","pippit_asset_id":"asset-1"}}`)
|
|
419
|
+
},
|
|
420
|
+
send: func(_ context.Context, _ string, _ any, out any) error {
|
|
421
|
+
return decodeInto(out, `{"ret":"0","data":{"Assets":[]}}`)
|
|
422
|
+
},
|
|
423
|
+
}
|
|
424
|
+
result, err := Upload(context.Background(), UploadOptions{
|
|
425
|
+
Path: path, PollInterval: 50 * time.Millisecond, WaitTimeout: time.Millisecond,
|
|
426
|
+
}, runnerWithClient(client))
|
|
427
|
+
if err != nil {
|
|
428
|
+
t.Fatalf("Upload() error = %v, want accepted processing result", err)
|
|
429
|
+
}
|
|
430
|
+
if result.State != StateProcessing || result.PippitAssetID != "asset-1" || result.Locator.PippitAssetID != "asset-1" {
|
|
431
|
+
t.Fatalf("Upload() = %#v, want durable ID after wait timeout", result)
|
|
432
|
+
}
|
|
433
|
+
if !strings.Contains(result.Warning, "was not queryable") {
|
|
434
|
+
t.Fatalf("warning = %q, want visibility warning", result.Warning)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
func TestEnvelopeRetIsStrict(t *testing.T) {
|
|
439
|
+
client := &fakeClient{send: func(_ context.Context, _ string, _ any, out any) error {
|
|
440
|
+
return decodeInto(out, `{"errmsg":"missing ret","log_id":"strict-log","data":{"Assets":[]}}`)
|
|
441
|
+
}}
|
|
442
|
+
_, err := Get(context.Background(), GetOptions{AssetIDs: []string{"1"}}, runnerWithClient(client))
|
|
443
|
+
if err == nil || !strings.Contains(err.Error(), `ret=""`) || !strings.Contains(err.Error(), "strict-log") {
|
|
444
|
+
t.Fatalf("Get() error = %v, want strict ret validation", err)
|
|
445
|
+
}
|
|
446
|
+
}
|