Files
ttstd-infinite-canvas/backend/internal/app/wavespeed_image_edit_test.go
Codex 6ee9d1dcb2 feat(image): integrate PoloX image editing workflows
Add WaveSpeed image editing and image tools plugins, canvas image editing workflows, layer selection, and Cloud Agent image tools with approval-safe transient references.
2026-09-18 20:34:28 +08:00

202 lines
9.1 KiB
Go

package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestWavespeedImageEditConformance 验证 wavespeed-image-edit 插件在宿主运行时的完整链路:
// create(POST /openai/gpt-image-2.5-sunburst/edit) -> taskId(data.id) -> poll(GET /predictions/{id}/result) -> outputs 结果映射。
func TestWavespeedImageEditConformance(t *testing.T) {
allowLoopbackProviderTest(t)
manifest := []byte(`{
"apiVersion":"yingce.plugin/v2",
"id":"test-wavespeed-image-edit","version":"1.0.0","name":"WaveSpeed Image Edit","author":"WaveSpeed / 影策","documentation":"# WaveSpeed Image Edit",
"permissions":["generation.run","media.read"],
"configuration":{"fields":[{"name":"apiKey","type":"secret","label":"WaveSpeed API Key","required":true}]},
"contributes":{"providers":[{
"id":"test-wavespeed-image-edit","label":"WaveSpeed Image Edit","capabilities":["image"],
"scopes":["admin.system-channel","user.custom-channel","canvas","creation","agent"],
"baseUrl":"https://api.wavespeed.ai/api/v3","requiresPublicMediaUrls":true,
"auth":{"type":"bearer","field":"apiKey"},
"parameters":[
{"name":"model","type":"string","required":true,"mapping":"model","description":"WaveSpeed 图片编辑模型端点 ID。"},
{"name":"prompt","type":"string","required":true,"mapping":"prompt","description":"编辑提示词。"},
{"name":"images","type":"media[]","required":false,"mapping":"provider image/reference fields","description":"编辑源图。"},
{"name":"aspectRatio","type":"string","required":false,"mapping":"size/aspect_ratio","description":"比例。"},
{"name":"quality","type":"string","required":false,"mapping":"quality","description":"质量。"},
{"name":"providerOptions","type":"object","required":false,"mapping":"provider-specific fields","description":"厂商扩展字段。"}
],
"create":{"method":"POST","path":"/{{model}}","contentType":"application/json","body":{
"prompt":{"$ref":"request.prompt"},
"images":{"$map":{"from":{"$ref":"request.images"},"as":"media","in":{"$ref":"media.value"}}},
"aspect_ratio":{"$omitEmpty":{"$ref":"request.aspectRatio"}},
"quality":{"$omitEmpty":{"$ref":"request.quality"}},
"output_format":{"$omitEmpty":{"$coalesce":[{"$ref":"request.providerOptions.wavespeed-image-edit.output_format"},"png"]}}
}},
"poll":{"method":"GET","path":"/predictions/{{taskId}}/result"},
"response":{
"taskId":{"$coalesce":[{"$ref":"response.data.id"},{"$ref":"response.id"}]},
"status":{"$coalesce":[{"$ref":"response.data.status"},{"$ref":"response.status"},"pending"]},
"message":{"$coalesce":[{"$ref":"response.data.error"},{"$ref":"response.data.message"},{"$ref":"response.error.message"},{"$ref":"response.message"}]},
"images":{"$ref":"response.data.outputs"},
"errorPaths":["error.code"],
"resultEphemeral":true
}
}]}
}`)
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, err := center.install(testPluginPackage(t, manifest), "wavespeed-image-edit.yingce-plugin"); err != nil {
t.Fatal(err)
}
createCalls := 0
pollCalls := 0
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/openai/gpt-image-2.5-sunburst/edit"):
createCalls++
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Errorf("decode create body: %v", err)
}
if body["prompt"] != "把背景换成海边日落" {
t.Errorf("create body prompt = %v", body["prompt"])
}
images, ok := body["images"].([]any)
if !ok || len(images) != 1 || images[0] != "https://cdn.example/source.png" {
t.Errorf("create body images = %#v", body["images"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-001"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/predictions/task-ws-001/result":
pollCalls++
if pollCalls == 1 {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-001","status":"processing","outputs":null}}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fmt.Sprintf(`{"code":200,"data":{"id":"task-ws-001","status":"completed","outputs":[%q,%q]}}`, server.URL+"/result-1.png", server.URL+"/result-2.png")))
case r.Method == http.MethodGet && (r.URL.Path == "/result-1.png" || r.URL.Path == "/result-2.png"):
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("fake-png"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := providerConfig{BaseURL: server.URL, APIKey: "ws-key", Model: "openai/gpt-image-2.5-sunburst/edit", APIFormat: "openai", InterfaceType: "test-wavespeed-image-edit"}
ctx := context.Background()
ctx = withProtocolRegistry(ctx, center.registrySnapshot())
result, err := runDeclarativeProtocolTask(ctx, canvasGenerationInput{
Mode: "image",
Prompt: "把背景换成海边日落",
Config: config,
ReferenceImages: []providerMedia{{URL: "https://cdn.example/source.png"}},
})
if err != nil {
t.Fatalf("runDeclarativeProtocolTask() error = %v", err)
}
if createCalls != 1 {
t.Fatalf("create calls = %d, want 1", createCalls)
}
if pollCalls < 2 {
t.Fatalf("poll calls = %d, want >= 2", pollCalls)
}
// 结果应包含两张输出图
images, ok := result["images"].([]interface{})
if !ok || len(images) != 2 {
raw, _ := json.Marshal(result)
t.Fatalf("result images type/count unexpected: %s", raw)
}
for i, rawImage := range images {
image, _ := rawImage.(map[string]interface{})
if strings.TrimSpace(fmt.Sprint(image["dataUrl"])) == "" || image["mimeType"] != "image/png" {
t.Fatalf("result image %d = %#v", i, image)
}
}
_ = fmt.Sprintf("%v", result["mode"])
}
// TestWavespeedImageEditPollFailure 验证 poll 返回 failed 时错误向上传播。
func TestWavespeedImageEditPollFailure(t *testing.T) {
allowLoopbackProviderTest(t)
manifest := []byte(`{
"apiVersion":"yingce.plugin/v2",
"id":"test-wavespeed-image-edit","version":"1.0.0","name":"WaveSpeed Image Edit","author":"WaveSpeed / 影策","documentation":"# WaveSpeed Image Edit",
"permissions":["generation.run","media.read"],
"configuration":{"fields":[{"name":"apiKey","type":"secret","label":"WaveSpeed API Key","required":true}]},
"contributes":{"providers":[{
"id":"test-wavespeed-image-edit","label":"WaveSpeed Image Edit","capabilities":["image"],
"scopes":["admin.system-channel","user.custom-channel","canvas","creation","agent"],
"baseUrl":"https://api.wavespeed.ai/api/v3","requiresPublicMediaUrls":true,
"auth":{"type":"bearer","field":"apiKey"},
"parameters":[
{"name":"model","type":"string","required":true,"mapping":"model","description":"WaveSpeed 图片编辑模型端点 ID。"},
{"name":"prompt","type":"string","required":true,"mapping":"prompt","description":"编辑提示词。"}
],
"create":{"method":"POST","path":"/{{model}}","contentType":"application/json","body":{
"prompt":{"$ref":"request.prompt"}
}},
"poll":{"method":"GET","path":"/predictions/{{taskId}}/result"},
"response":{
"taskId":{"$coalesce":[{"$ref":"response.data.id"},{"$ref":"response.id"}]},
"status":{"$coalesce":[{"$ref":"response.data.status"},{"$ref":"response.status"},"pending"]},
"message":{"$coalesce":[{"$ref":"response.data.error"},{"$ref":"response.data.message"},{"$ref":"response.error.message"},{"$ref":"response.message"}]},
"images":{"$ref":"response.data.outputs"},
"errorPaths":["error.code"],
"resultEphemeral":true
}
}]}
}`)
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
if _, err := center.install(testPluginPackage(t, manifest), "wavespeed-image-edit.yingce-plugin"); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/v1/openai/gpt-image-2.5-sunburst/edit"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-fail"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/predictions/task-ws-fail/result":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":200,"data":{"id":"task-ws-fail","status":"failed","error":"content policy violation"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
config := providerConfig{BaseURL: server.URL, APIKey: "ws-key", Model: "openai/gpt-image-2.5-sunburst/edit", APIFormat: "openai", InterfaceType: "test-wavespeed-image-edit"}
ctx := context.Background()
ctx = withProtocolRegistry(ctx, center.registrySnapshot())
_, err = runDeclarativeProtocolTask(ctx, canvasGenerationInput{
Mode: "image",
Prompt: "edit",
Config: config,
})
if err == nil {
t.Fatal("expected error on failed poll, got nil")
}
if !strings.Contains(err.Error(), "content policy violation") {
t.Fatalf("error = %v, want content policy violation", err)
}
}