fix(release): 发布回归 - 修复资源引用保护并校准验证合同
This commit is contained in:
@@ -168,7 +168,8 @@ func TestDuplicateSystemChannelCopiesSecretsModelsAndPriceTiers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
|
||||
request := ChannelRequest{Name: "Headers", BaseURL: "https://example.com/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}}
|
||||
// URL validation is real, but this non-network test must not depend on DNS.
|
||||
request := ChannelRequest{Name: "Headers", BaseURL: "https://93.184.216.34/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}}
|
||||
channel, err := channelFromRequest(request, model.ModelChannel{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -217,7 +218,7 @@ func TestRuntimeConcurrencyUsesEnvironmentFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
useGlobal := true
|
||||
channel, err := channelFromRequest(ChannelRequest{Name: "Global", BaseURL: "https://example.com/v1", UseGlobalConcurrency: &useGlobal}, model.ModelChannel{ConcurrencyLimit: 4})
|
||||
channel, err := channelFromRequest(ChannelRequest{Name: "Global", BaseURL: "https://93.184.216.34/v1", UseGlobalConcurrency: &useGlobal}, model.ModelChannel{ConcurrencyLimit: 4})
|
||||
if err != nil || channel.ConcurrencyLimit != 0 {
|
||||
t.Fatalf("global concurrency channel = %#v, error = %v", channel, err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -133,7 +135,7 @@ func TestPrepareOfficialGeminiAgentCacheCreatesAndReusesStablePrefix(t *testing.
|
||||
stableInstruction := strings.Repeat("stable system instruction ", 1000)
|
||||
spec := protocol.RequestSpec{
|
||||
Method: http.MethodPost,
|
||||
Path: "/models/gemini-test:generateContent",
|
||||
Path: "/v1beta/models/gemini-test:generateContent",
|
||||
ContentType: "application/json",
|
||||
Body: map[string]any{
|
||||
"systemInstruction": map[string]any{"parts": []any{map[string]any{"text": stableInstruction}}},
|
||||
@@ -142,6 +144,24 @@ func TestPrepareOfficialGeminiAgentCacheCreatesAndReusesStablePrefix(t *testing.
|
||||
"contents": []any{map[string]any{"role": "user", "parts": []any{map[string]any{"text": "hello"}}}},
|
||||
},
|
||||
}
|
||||
// Build the request through the shipped plugin, so the fixture cannot
|
||||
// silently diverge from its versioned endpoint or body mapping.
|
||||
manifest, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "google-gemini-generate-content", "manifest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adapter, err := protocol.LoadManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agentAdapter, ok := adapter.(protocol.AgentAdapter)
|
||||
if !ok {
|
||||
t.Fatal("official Gemini plugin does not support Agent requests")
|
||||
}
|
||||
spec, err = agentAdapter.BuildAgent(ctx, protocol.AgentRequestContext{BaseURL: server.URL, Model: input.Config.Model, Request: map[string]any{"gemini": spec.Body}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prepared, used, err := prepareOfficialGeminiAgentCache(ctx, input, spec)
|
||||
if err != nil {
|
||||
|
||||
@@ -47,7 +47,8 @@ func TestProviderPollingDeadlineDefaultsToOneHour(t *testing.T) {
|
||||
|
||||
func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) {
|
||||
svc := &Service{}
|
||||
input, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)}})
|
||||
// Only validate policy here; a public IP keeps SSRF checks without external DNS.
|
||||
input, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://93.184.216.34", InterfaceType: string(model.ChannelInterfaceNewAPIChannel2)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,7 +84,7 @@ func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) {
|
||||
if svc.shouldDeferVideoProviderTask(base, string(input), providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}) {
|
||||
t.Fatal("untyped provider error must not be deferred")
|
||||
}
|
||||
other, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIVideo)}})
|
||||
other, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://93.184.216.34", InterfaceType: string(model.ChannelInterfaceNewAPIVideo)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -102,7 +103,7 @@ func TestResumableVideoDeadlineUsesResolvedSystemChannelProtocol(t *testing.T) {
|
||||
}
|
||||
channel := model.ModelChannel{
|
||||
ID: "channel-video", UserID: "admin", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Video",
|
||||
BaseURL: "https://example.com", APIKey: "test-key", APIFormat: "openai", ModelsJSON: `["video-model"]`,
|
||||
BaseURL: "https://93.184.216.34", APIKey: "test-key", APIFormat: "openai", ModelsJSON: `["video-model"]`,
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "model-video", ChannelID: channel.ID, ModelKey: "video-model", Capability: "video",
|
||||
|
||||
91
backend/internal/database/prefixed_ids_test.go
Normal file
91
backend/internal/database/prefixed_ids_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func prefixedIDTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := Open(Config{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "ids.db")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}, &model.IDSequence{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestAllocatePrefixedIDReservesDeletedAndExistingIDs(t *testing.T) {
|
||||
db := prefixedIDTestDB(t)
|
||||
for _, id := range []string{"CHANNEL_000012", "CHANNEL_000030", "CHANNEL_bad", "OTHER_999999", "CHANNEL_999999999999999999999999"} {
|
||||
if err := db.Create(&model.ModelChannel{ID: id}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&model.ModelChannel{}, "id = ?", "CHANNEL_000030").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.IDSequence{Name: "id:CHANNEL", Value: 2}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"CHANNEL_000031", "CHANNEL_000032"} {
|
||||
got, err := AllocatePrefixedID(db, " channel ")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("got %q, %v; want %q", got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcilePrefixedIDSequencesNeverRegresses(t *testing.T) {
|
||||
db := prefixedIDTestDB(t)
|
||||
if err := db.Create(&model.ModelChannel{ID: "CHANNEL_000030"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []int64{30, 30, 100, 100} {
|
||||
if want == 100 {
|
||||
if err := db.Model(&model.IDSequence{}).Where("name = ?", "id:CHANNEL").Update("value", want).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := ReconcilePrefixedIDSequences(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sequence model.IDSequence
|
||||
if err := db.First(&sequence, "name = ?", "id:CHANNEL").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sequence.Value != want {
|
||||
t.Fatalf("got %d; want %d", sequence.Value, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocatePrefixedIDRejectsOverflowWithoutMutation(t *testing.T) {
|
||||
db := prefixedIDTestDB(t)
|
||||
if err := db.Create(&model.IDSequence{Name: "id:CHANNEL", Value: math.MaxInt64}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := AllocatePrefixedID(db, "CHANNEL")
|
||||
if err == nil || got != "" {
|
||||
t.Fatalf("expected no ID and overflow error, got %q, %v", got, err)
|
||||
}
|
||||
var sequence model.IDSequence
|
||||
if err := db.First(&sequence, "name = ?", "id:CHANNEL").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sequence.Value != math.MaxInt64 {
|
||||
t.Fatalf("overflow mutated sequence: %d", sequence.Value)
|
||||
}
|
||||
}
|
||||
@@ -297,16 +297,17 @@ func (r *Repository) ResourceReferenceSnapshotExcludingAssets(userID string, exc
|
||||
}
|
||||
|
||||
type joinedRepresentation struct {
|
||||
ID string
|
||||
Title string
|
||||
ResourceID string
|
||||
ID string
|
||||
Title string
|
||||
ResourceID string
|
||||
MetadataJSON string
|
||||
}
|
||||
var representations []joinedRepresentation
|
||||
representationQuery := r.db.Table("asset_representations").
|
||||
Select("asset_representations.id, assets.title, asset_representations.resource_id").
|
||||
Select("asset_representations.id, assets.title, asset_representations.resource_id, asset_representations.metadata_json").
|
||||
Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id").
|
||||
Joins("JOIN assets ON assets.id = asset_versions.asset_id").
|
||||
Where("assets.user_id = ? AND asset_representations.resource_id IN ?", userID, resourceIDs)
|
||||
Where("assets.user_id = ?", userID)
|
||||
if len(excludingAssetIDs) > 0 {
|
||||
representationQuery = representationQuery.Where("assets.id NOT IN ?", excludingAssetIDs)
|
||||
}
|
||||
@@ -315,6 +316,7 @@ func (r *Repository) ResourceReferenceSnapshotExcludingAssets(userID string, exc
|
||||
}
|
||||
for _, representation := range representations {
|
||||
snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, Title: representation.Title, ResourceID: representation.ResourceID})
|
||||
snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: representation.ID, Title: representation.Title, PrimaryJSON: representation.MetadataJSON})
|
||||
}
|
||||
|
||||
var voices []model.VoiceProfile
|
||||
|
||||
@@ -13,9 +13,17 @@ description: 影策后端核心持久化表与状态边界
|
||||
|
||||
后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。结构变化由 `migrate-schema` 按版本顺序执行并写入 `schema_migrations`;受管生产部署在 Backend 启动前运行独立迁移服务,Backend 只校验当前 Schema 是否与二进制兼容。业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。
|
||||
|
||||
## 素材资源引用边界
|
||||
|
||||
资源引用快照同时读取素材表现的 `resource_id` 和 `metadata_json`(包括嵌套资源字段);不能先按 `resource_id` 过滤表现记录,否则仅在 metadata 中引用的共享资源会漏检。素材批量删除保留其他素材仍引用的资源;管理员删除也复用这一引用边界。
|
||||
|
||||
## 结构迁移与版本
|
||||
|
||||
当前结构版本为 35。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 35,回退需恢复匹配的数据库备份。关闭自动迁移的生产环境必须运行正式的 v35 migration;仅在数据库中手工补表不能更新 `schema_migrations` 的版本与校验和记录。
|
||||
当前结构版本为 38。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。
|
||||
|
||||
版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。
|
||||
|
||||
升级前备份,按既有流程执行 `migrate-schema up` 和 `verify` 后再启动新后端。旧版本二进制不支持版本 38,回退需恢复匹配的数据库备份,并评估备份之后的新写入;不能只切回旧镜像。关闭自动迁移的生产环境必须运行正式迁移;手工改表不能更新 `schema_migrations` 的版本与校验和记录。
|
||||
|
||||
### 生成成功后的作品保存恢复
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 发布回归与资源引用保护
|
||||
|
||||
- 已修复:素材表现只在 `metadata_json` 引用资源时,批量删除和管理员资源检查仍识别该引用,不误删共享资源。已有回归覆盖共享对象、跨用户别名、事务回滚和删除队列。
|
||||
- 已更新:启动并行外观加载、主题联动、统一下拉框、画布首帧与 Agent 插话的回归断言;资源消费竞态测试通过模拟 HTTP 传输验证真实持久化链路,不依赖外部网络。
|
||||
- [ ] 升级到 schema 38 前备份数据库,执行正式迁移及 verify;验证已有渠道和逻辑模型继续生成唯一可读 ID。
|
||||
- [ ] 登录真实页面验收素材删除、生成结果转画布、Agent 插话、SSE 断线续传及资源访问。健康检查不能代替这些路径。
|
||||
|
||||
## 注册服务协议后台配置
|
||||
|
||||
- 已实现:协议名称与条款正文由后台「登录与注册」第 3 区块配置,经 `PATCH /admin/settings/registration` 保存,公开下发在 `GET /auth/settings` 的 `agreementTitle` / `agreementContent`。标题未配置时跟随外观品牌名生成;正文按空行分段渲染,留空时注册页提示待补充。注册开关单独切换不会覆盖协议(后端按可选指针字段合并)。
|
||||
|
||||
@@ -16,7 +16,9 @@ test("运行中不再锁死输入框,发送走插话而不是新建轮次", as
|
||||
|
||||
// 输入框的禁用条件里不能再有 running —— 只保留「连不上事件流」「历史/待确认未水合」这类真不能发的状态
|
||||
expect(panel).toContain('disabled={Boolean(run && connectionStatus !== "connected")');
|
||||
expect(panel).not.toContain("disabled={busy || running ||");
|
||||
const composer = panel.match(/<AgentChatComposer[\s\S]*?disabled=\{([^\n]+)\}/)?.[1];
|
||||
expect(composer).toBeDefined();
|
||||
expect(composer).not.toMatch(/\bbusy\b|\brunning\b/);
|
||||
// 停止按钮由 running 驱动,发送按钮由我们自己的请求驱动,两者不再互斥
|
||||
expect(panel).toContain("sending={busy}");
|
||||
expect(panel).toContain("running={running}");
|
||||
|
||||
@@ -88,8 +88,9 @@ describe("Agent tool presentation", () => {
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "generation_task_created" })).toBe("媒体节点已创建,生成任务已提交");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_completed" })).toBe("生成结果已回写画布节点");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed" })).toBe("媒体生成未完成");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "admission", taskSubmitted: false } })).toBe("生成请求未提交");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "completion", status: "succeeded" } })).toBe("生成成功,画布回写未完成");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "admission", taskSubmitted: false } })).toBe("参数未通过校验,未提交生成任务");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "completion", taskSubmitted: true, status: "succeeded" } })).toBe("生成成功,画布回写未完成");
|
||||
expect(friendlyAgentToolSummary("generate_media", "", { eventType: "tool_failed", result: { phase: "completion", taskSubmitted: true, status: "failed" } })).toBe("任务已提交,但生成结果未完成");
|
||||
});
|
||||
it("uses the failure event even without error keywords", () => {
|
||||
const text = "技能未在本轮启用,或参考文件未包含在固定快照中";
|
||||
|
||||
@@ -17,11 +17,17 @@ describe("shared action colors and focus feedback", () => {
|
||||
test(`custom primary action states reach product and admin in ${dark ? "dark" : "light"} mode`, () => {
|
||||
const skin = duplicateSkinDefinition(DEFAULT_CLASSIC_SKIN, ["classic"]);
|
||||
Object.assign(skin.tokens[dark ? "dark" : "light"], {
|
||||
primary: "#123456", primaryHover: "#234567", primaryActive: "#345678", primaryForeground: "#abcdef",
|
||||
primary: "#123456",
|
||||
primaryHover: "#234567",
|
||||
primaryActive: "#345678",
|
||||
primaryForeground: "#abcdef",
|
||||
});
|
||||
for (const theme of [getAntThemeConfig(dark, skin), getIsolatedAdminAntTheme(dark, skin)]) {
|
||||
expect(theme.components?.Button).toMatchObject({
|
||||
colorPrimary: "#123456", colorPrimaryHover: "#234567", colorPrimaryActive: "#345678", primaryColor: "#abcdef",
|
||||
colorPrimary: "#123456",
|
||||
colorPrimaryHover: "#234567",
|
||||
colorPrimaryActive: "#345678",
|
||||
primaryColor: "#abcdef",
|
||||
});
|
||||
expect(theme.token?.controlOutlineWidth).toBe(0);
|
||||
expect(theme.components?.Input?.activeShadow).toBe("none");
|
||||
@@ -36,6 +42,6 @@ describe("shared action colors and focus feedback", () => {
|
||||
const theme = getWorkspaceAntThemeConfig();
|
||||
expect(theme.components?.Button?.colorPrimary).toBeUndefined();
|
||||
expect(theme.components?.Button?.primaryColor).toBeUndefined();
|
||||
expect(theme.components?.Input?.activeShadow).toBeUndefined();
|
||||
expect(theme.components?.Input?.activeShadow).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,7 +76,8 @@ test("auth scene consumes resolved appearance instead of hardcoded media constan
|
||||
expect(source).toContain("appearance.brandName");
|
||||
expect(source).toContain("appearance.authHeroTitle");
|
||||
expect(source).toContain("appearance.authHeroDescription");
|
||||
expect(source).toContain('theme="dark"');
|
||||
expect(source).toContain("useThemeStore((state) => state.theme)");
|
||||
expect(source).toContain("theme={theme}");
|
||||
expect(source).not.toContain("让一个故事,");
|
||||
expect(source).not.toContain("AUTH_VIDEO_URL");
|
||||
expect(source).not.toContain("AUTH_VIDEO_POSTER");
|
||||
|
||||
@@ -33,7 +33,9 @@ async function prepareEntry(dev, pathname) {
|
||||
let entryLoaded;
|
||||
const appearanceReady = new Promise((resolve) => (resolveAppearance = resolve));
|
||||
const loaded = new Promise((resolve) => (entryLoaded = resolve));
|
||||
runInNewContext(await build.outputs[0].text(), { window: { location: { pathname } }, events, appearanceReady, entryLoaded });
|
||||
const listeners = new Map();
|
||||
runInNewContext(await build.outputs[0].text(), { window: { location: { pathname }, addEventListener: (name, listener) => listeners.set(name, listener) }, events, appearanceReady, entryLoaded });
|
||||
expect(typeof listeners.get("vite:preloadError")).toBe("function");
|
||||
return { events, resolveAppearance, loaded };
|
||||
}
|
||||
|
||||
@@ -50,12 +52,11 @@ for (const [dev, pathname] of [
|
||||
[true, "/dev/director-repro/"],
|
||||
[true, "/dev/director-repro-other"],
|
||||
]) {
|
||||
test(`appearance still blocks normal startup: dev=${dev} path=${pathname}`, async () => {
|
||||
test(`appearance loads in parallel with normal startup: dev=${dev} path=${pathname}`, async () => {
|
||||
const entry = await prepareEntry(dev, pathname);
|
||||
expect(entry.events).toEqual(["appearance"]);
|
||||
entry.resolveAppearance();
|
||||
await entry.loaded;
|
||||
expect(entry.events).toEqual(["appearance", "./application"]);
|
||||
entry.resolveAppearance();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
import localforage from "localforage";
|
||||
|
||||
import { apiClient } from "@/services/api/request";
|
||||
import { deleteAssetsWithRemoteSync, initializeRemoteUserDataSession, loadAssetLibraryPage, resetRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
@@ -8,35 +9,75 @@ import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/
|
||||
import { appQueryClient } from "@/lib/query-client";
|
||||
|
||||
const originalAdapter = apiClient.defaults.adapter;
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
const originalGet = localforage.getItem;
|
||||
const originalSet = localforage.setItem;
|
||||
const originalRemove = localforage.removeItem;
|
||||
async function within<T>(promise: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000);
|
||||
})]);
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
const asset = (id: string): Asset => ({
|
||||
id, kind: "text", title: id, coverUrl: "", tags: [],
|
||||
createdAt: "2026-09-21T00:00:00.000Z", updatedAt: "2026-09-21T00:00:00.000Z",
|
||||
data: { content: "测试素材" },
|
||||
} as Asset);
|
||||
const asset = (id: string): Asset =>
|
||||
({
|
||||
id,
|
||||
kind: "text",
|
||||
title: id,
|
||||
coverUrl: "",
|
||||
tags: [],
|
||||
createdAt: "2026-09-21T00:00:00.000Z",
|
||||
updatedAt: "2026-09-21T00:00:00.000Z",
|
||||
data: { content: "测试素材" },
|
||||
}) as Asset;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const storage = new Map<string, string>();
|
||||
const indexed = new Map<string, unknown>();
|
||||
localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem;
|
||||
localforage.setItem = (async (key: string, value: unknown) => {
|
||||
indexed.set(key, value);
|
||||
return value;
|
||||
}) as typeof localforage.setItem;
|
||||
localforage.removeItem = async (key: string) => {
|
||||
indexed.delete(key);
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
localStorage: { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key) },
|
||||
},
|
||||
});
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
await initializeRemoteUserDataSession("batch-test-user");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await appQueryClient.cancelQueries();
|
||||
appQueryClient.clear();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
try {
|
||||
await appQueryClient.cancelQueries();
|
||||
appQueryClient.clear();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
resetRemoteUserDataSync();
|
||||
useAssetStore.setState({ assets: [] });
|
||||
await flushAssetStorePersistence();
|
||||
} finally {
|
||||
localforage.getItem = originalGet;
|
||||
localforage.setItem = originalSet;
|
||||
localforage.removeItem = originalRemove;
|
||||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow);
|
||||
else delete (globalThis as { window?: unknown }).window;
|
||||
}
|
||||
});
|
||||
|
||||
describe("素材批量删除", () => {
|
||||
@@ -50,23 +91,35 @@ describe("素材批量删除", () => {
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { ids }, msg: "ok" } };
|
||||
};
|
||||
let updates = 0;
|
||||
const stop = useAssetStore.subscribe((state, previous) => { if (state.assets !== previous.assets) updates++; });
|
||||
const stop = useAssetStore.subscribe((state, previous) => {
|
||||
if (state.assets !== previous.assets) updates++;
|
||||
});
|
||||
try {
|
||||
await deleteAssetsWithRemoteSync(ids);
|
||||
expect(requests).toEqual([{ method: "post", url: "/assets/batch-delete", body: ids }]);
|
||||
expect(useAssetStore.getState().assets.map((item) => item.id)).toEqual(["keep"]);
|
||||
expect(updates).toBe(1);
|
||||
} finally { stop(); }
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])("真实分页查询刷新不阻塞删除完成(刷新失败:%s)", async (failRefresh) => {
|
||||
const initialPage = {
|
||||
assets: [asset("first"), asset("keep")], total: 2, page: 1, pageSize: 20,
|
||||
kindCounts: {}, categoryCounts: {}, folderCounts: {}, hasMore: false,
|
||||
assets: [asset("first"), asset("keep")],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
kindCounts: {},
|
||||
categoryCounts: {},
|
||||
folderCounts: {},
|
||||
hasMore: false,
|
||||
};
|
||||
useAssetStore.setState({ assets: initialPage.assets });
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshGate = new Promise<void>((resolve) => { releaseRefresh = resolve; });
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const requests: string[] = [];
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
requests.push(`${config.method} ${config.url}`);
|
||||
@@ -75,22 +128,38 @@ describe("素材批量删除", () => {
|
||||
}
|
||||
await refreshGate;
|
||||
if (failRefresh) throw new Error("列表暂时不可用");
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: {
|
||||
code: 0, data: { ...initialPage, assets: [asset("keep")], total: 1 }, msg: "ok",
|
||||
} };
|
||||
return {
|
||||
config,
|
||||
headers: {},
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
data: {
|
||||
code: 0,
|
||||
data: { ...initialPage, assets: [asset("keep")], total: 1 },
|
||||
msg: "ok",
|
||||
},
|
||||
};
|
||||
};
|
||||
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
||||
const observers = ["asset-library", "asset-picker"].map((key) => new QueryObserver(appQueryClient, {
|
||||
queryKey: [key, "delete-regression"],
|
||||
queryFn: () => loadAssetLibraryPage({ page: 1, pageSize: 20 }),
|
||||
initialData: initialPage,
|
||||
}));
|
||||
const observers = ["asset-library", "asset-picker"].map(
|
||||
(key) =>
|
||||
new QueryObserver(appQueryClient, {
|
||||
queryKey: [key, "delete-regression"],
|
||||
queryFn: () => loadAssetLibraryPage({ page: 1, pageSize: 20 }),
|
||||
initialData: initialPage,
|
||||
}),
|
||||
);
|
||||
const stops: (() => void)[] = [];
|
||||
const refreshed = observers.map((observer) => new Promise<void>((resolve) => {
|
||||
stops.push(observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve();
|
||||
}));
|
||||
}));
|
||||
const refreshed = observers.map(
|
||||
(observer) =>
|
||||
new Promise<void>((resolve) => {
|
||||
stops.push(
|
||||
observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve();
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
try {
|
||||
// GET 尚未返回,删除必须已返回,调用方才能关闭 Modal / Popconfirm。
|
||||
await within(deleteAssetsWithRemoteSync(["first"]));
|
||||
|
||||
@@ -78,18 +78,18 @@ describe("large canvas media rendering", () => {
|
||||
const inactivePreviewSource = canvasNodeContentSource.match(/function InactiveVideoPreview[\s\S]*?\n}\n\nfunction VideoPreviewPlayButton/)?.[0] || "";
|
||||
expect(canvasNodeContentSource).toContain("if (hasPersistedPreview || !nearViewport || (!node.metadata?.content && !node.metadata?.storageKey) || !updateMetadataRef.current)");
|
||||
expect(canvasNodeContentSource).not.toContain("hydrateMediaPreview");
|
||||
expect(inactivePreviewSource).toContain("hasPersistedPreview || localPreviewUrl || !nearViewport");
|
||||
expect(inactivePreviewSource).toContain("<video");
|
||||
expect(inactivePreviewSource).toContain("muted");
|
||||
expect(inactivePreviewSource).toContain('preload="auto"');
|
||||
expect(inactivePreviewSource).toContain("video.currentTime = Math.min(0.001, video.duration / 2)");
|
||||
expect(inactivePreviewSource).toContain("onCanPlay={() => setPassiveVideoReady(true)}");
|
||||
expect(inactivePreviewSource).toContain("hasPersistedPreview || !nearViewport");
|
||||
expect(inactivePreviewSource).toContain("URL.revokeObjectURL(localPreviewUrlRef.current)");
|
||||
expect(inactivePreviewSource).not.toContain("<video");
|
||||
expect(inactivePreviewSource).toContain("hydrateCanvasVideoPreview(node, controller.signal)");
|
||||
expect(canvasVideoPreviewSource).toContain("captureVideoPoster(source, { signal, maxWidth: 400 })");
|
||||
expect(inactivePreviewSource).toContain("hydrated.persisted.then");
|
||||
expect(inactivePreviewSource).not.toContain("autoPlay");
|
||||
expect(inactivePreviewSource).toContain("<VideoPreviewPlayButton");
|
||||
expect(canvasNodeContentSource).toContain("useVideoPlaybackUrl(node, mediaActive)");
|
||||
expect(canvasNodeContentSource).toContain("onMediaPlayRequest?.(node.id)");
|
||||
expect(canvasNodeContentSource).toContain('autoPlay preload="metadata"');
|
||||
expect(canvasNodeContentSource).toContain("scheduleResourceBlobCache(node.metadata?.storageKey || \"\")");
|
||||
expect(canvasNodeContentSource).toMatch(/autoPlay\s+preload="metadata"/);
|
||||
expect(canvasNodeContentSource).toContain("resolveMediaUrl(storageKey, fallback)");
|
||||
});
|
||||
|
||||
test("downloads OSS media by browser navigation without fetching it into a Blob", () => {
|
||||
@@ -120,7 +120,7 @@ describe("audio canvas interaction", () => {
|
||||
expect(canvasNodeContentSource.match(/function AudioNodeContent[\s\S]*?\n}\n/)?.[0] || "").not.toContain("useNodeResourceUrl");
|
||||
expect(canvasAudioPlayerSource).not.toContain("播放结束,点击重新播放");
|
||||
expect(canvasAudioPlayerSource).not.toContain("pr-24");
|
||||
expect(canvasAudioPlayerSource).toContain("formatAudioTime(snapshot.currentTimeMs)}/{durationMs ? formatAudioTime(durationMs) : \"--:--\"");
|
||||
expect(canvasAudioPlayerSource).toContain('formatAudioTime(snapshot.currentTimeMs)}/{durationMs ? formatAudioTime(durationMs) : "--:--"');
|
||||
});
|
||||
|
||||
test("isolates player controls from canvas gestures", () => {
|
||||
@@ -130,7 +130,6 @@ describe("audio canvas interaction", () => {
|
||||
expect(canvasAudioPlayerSource).toContain("onClick={(event) => {");
|
||||
expect(canvasAudioPlayerSource).toContain("togglePlayback();");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("video canvas controls", () => {
|
||||
@@ -175,7 +174,7 @@ describe("video canvas controls", () => {
|
||||
expect(playerCSS).toContain("--media-slider-width: var(--video-volume-slider-width)");
|
||||
expect(playerCSS).toContain('.canvas-video-player[data-no-audio="true"] .vds-volume .vds-mute-button');
|
||||
expect(playerCSS).toContain("cursor: not-allowed;");
|
||||
expect(playerCSS).toContain("data-player-variant=\"compact\"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen)");
|
||||
expect(playerCSS).toContain('data-player-variant="compact"]:is([data-fullscreen], :fullscreen, :-webkit-full-screen)');
|
||||
expect(playerCSS).toContain("height: var(--canvas-video-control-size);");
|
||||
expect(playerCSS).toContain("min-height: var(--canvas-video-control-size);");
|
||||
expect(playerCSS).toContain("padding: 0 0 0 calc(var(--canvas-video-control-size)");
|
||||
@@ -209,7 +208,7 @@ describe("video canvas controls", () => {
|
||||
expect(videoPlayerSource).not.toContain("onPointerDownCapture={stopCanvasControlInteraction}");
|
||||
expect(videoPlayerSource).not.toContain("onMouseDownCapture={stopCanvasControlInteraction}");
|
||||
expect(videoPlayerSource).not.toContain("onClickCapture={stopCanvasControlClick}");
|
||||
expect(canvasNodeContentSource).toContain('hasAudio={inferVideoHasAudio(node.metadata)} autoPlay preload="metadata"');
|
||||
expect(canvasNodeContentSource).toMatch(/hasAudio=\{inferVideoHasAudio\(node.metadata\)\}\s+autoPlay\s+preload="metadata"/);
|
||||
expect(canvasNodeContentSource).toContain('if (["false", "0", "off", "no", "disabled"].includes(value || "")) return false;');
|
||||
});
|
||||
|
||||
@@ -239,13 +238,14 @@ describe("video canvas controls", () => {
|
||||
test("probes remote MP4 ranges when browser audio APIs are unavailable", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const movie = isoBmffMovie(["vide"]);
|
||||
globalThis.fetch = (async () => new Response(movie, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"content-range": `bytes 0-${movie.byteLength - 1}/${movie.byteLength}`,
|
||||
"content-length": String(movie.byteLength),
|
||||
},
|
||||
})) as typeof fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(movie, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"content-range": `bytes 0-${movie.byteLength - 1}/${movie.byteLength}`,
|
||||
"content-length": String(movie.byteLength),
|
||||
},
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
expect(await detectVideoAudioTrackFromUrl(`https://media.example/silent-${movie.byteLength}.mp4`)).toBe(false);
|
||||
} finally {
|
||||
@@ -315,7 +315,7 @@ describe("passive video previews", () => {
|
||||
expect(canvasMentionSource).toContain("onloadedmetadata = () => primeVideoPreviewFrame(media)");
|
||||
expect(canvasMentionSource).toContain("onLoadedMetadata={(event) => primeVideoPreviewFrame(event.currentTarget)}");
|
||||
expect(canvasMentionSource).toContain("video.currentTime = Math.min(0.001, video.duration)");
|
||||
expect(canvasMentionSource).toContain("reference.kind === \"video\" && reference.previewUrl");
|
||||
expect(canvasMentionSource).toContain('reference.kind === "video" && reference.previewUrl');
|
||||
});
|
||||
|
||||
test("persists uploaded posters in video node metadata and prefers them over legacy previews", () => {
|
||||
|
||||
@@ -11,15 +11,15 @@ describe("canvas node title interaction", () => {
|
||||
test("disables iframe hit testing only during node dragging", () => {
|
||||
expect(canvasStylesSource).toMatch(/\[data-canvas-node-dragging="true"\] \.node-element iframe\s*\{\s*pointer-events: none;/);
|
||||
expect(liveViewportSource).toContain('if (preview) container.dataset.canvasNodeDragging = "true"');
|
||||
expect(liveViewportSource).toContain('else delete container.dataset.canvasNodeDragging');
|
||||
expect(liveViewportSource).toContain("else delete container.dataset.canvasNodeDragging");
|
||||
});
|
||||
test("exposes a drag handle without bypassing read-only or locked nodes", () => {
|
||||
expect(nodeSource).toContain('onDragStart={readOnly ? undefined : (event) => onMouseDown(event, data.id)}');
|
||||
expect(nodeSource).toContain('disabled={!onDragStart || Boolean(node.metadata?.locked)}');
|
||||
expect(nodeSource).toContain('onDragStart?.(event)');
|
||||
expect(nodeSource).toContain('event.currentTarget.setPointerCapture(event.pointerId)');
|
||||
expect(nodeSource).toContain('拖动此处移动节点;点击名称可重命名');
|
||||
expect(nodeSource).toContain('onClick={onEdit}');
|
||||
test("keeps title editing separate from node-body dragging and protects locked titles", () => {
|
||||
expect(nodeSource).toContain("editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}");
|
||||
expect(nodeSource).toContain("onMouseDown={(event) => onMouseDown(event, data.id)}");
|
||||
const selection = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-selection-controller.ts"), "utf8");
|
||||
expect(selection).toContain("if (clickedNode?.metadata?.locked)");
|
||||
expect(selection).toContain("!node.metadata?.locked");
|
||||
expect(nodeSource).toContain("onClick={onEdit}");
|
||||
});
|
||||
test("keeps the toolbar hover bridge from intercepting the external title", () => {
|
||||
const hoverBridge = canvasStylesSource.match(/\.canvas-node-toolbar::after\s*\{([\s\S]*?)\}/)?.[1] || "";
|
||||
|
||||
@@ -140,9 +140,8 @@ describe("canvas resource mention editor", () => {
|
||||
expect(component).toContain('chip.style.setProperty("--canvas-skill-mention-color", skillMentionColor(reference))');
|
||||
expect(chat).toContain('sendOnEnter={canSubmit ? "both" : false}');
|
||||
expect(chat).toContain("agent-composer-resize-handle");
|
||||
expect(chat).toContain("Enter 发送 · Shift+Enter 换行");
|
||||
expect(css).toContain(".agent-composer-send-hint-full");
|
||||
expect(css).toContain(".agent-composer-send-hint-compact");
|
||||
expect(chat).toContain("Enter 或 ⌘/Ctrl+Enter 发送");
|
||||
expect(chat).toContain('title={canStop ? "插话:Agent 下一次开口时看到它"');
|
||||
expect(css).toContain(".agent-composer-prompt-scroll");
|
||||
expect(css).not.toContain(".agent-tool-row:hover");
|
||||
});
|
||||
|
||||
@@ -95,7 +95,6 @@ test("Create refresh subscriptions share one durable scheduler observation witho
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("task subscription can reconnect after an observation failure", async () => {
|
||||
let queryCalls = 0;
|
||||
const terminal: GenerationTask = {
|
||||
@@ -138,10 +137,7 @@ test("task subscription can reconnect after an observation failure", async () =>
|
||||
});
|
||||
|
||||
test("Create durably correlates failures that happen before the first Runtime response", async () => {
|
||||
const source = [
|
||||
await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(),
|
||||
await Bun.file(new URL("../src/pages/create/creation-types.ts", import.meta.url)).text(),
|
||||
].join("\n");
|
||||
const source = [await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(), await Bun.file(new URL("../src/pages/create/creation-types.ts", import.meta.url)).text()].join("\n");
|
||||
expect(source).toContain("generationErrorCode?: string");
|
||||
expect(source).toContain("generationOperation?: string");
|
||||
expect(source).toContain("generationOperation: task.operation");
|
||||
@@ -363,7 +359,7 @@ test("Create forwards owned result assets through one new canvas and the project
|
||||
|
||||
expect(workspace).toContain("onContinueCanvas(resultAssetIds)");
|
||||
expect(createPage).toContain("continueCreationConversationOnCanvas(source)");
|
||||
expect(createPage).toContain("if (ids.length !== item.resultUrls.length) throw new Error");
|
||||
expect(createPage).toContain("if (ids.length !== Math.max(resultStorageKeys.length, resultUrls.length)) throw new Error");
|
||||
expect(createPage).toContain("await saveCreationConversations(next)");
|
||||
expect(createPage.indexOf("await saveCreationConversations(next)")).toBeLessThan(createPage.indexOf("navigate(`/canvas/${result.id}?${params.toString()}`)"));
|
||||
expect(canvasIndex).toContain('const handoffMode = mode === "handoff"');
|
||||
|
||||
@@ -396,6 +396,29 @@ async function runMediaCommitRace(mediaType: "video" | "audio") {
|
||||
async function runCanvasBatchCommitRace() {
|
||||
const harness = installStorageHarness();
|
||||
const previousScope = getActiveUserScope();
|
||||
const { apiClient } = await import("../../src/services/api/request");
|
||||
const originalAdapter = apiClient.defaults.adapter;
|
||||
apiClient.defaults.adapter = async (config) => {
|
||||
if (config.method !== "post" || config.url !== "/resources/access") throw new Error(`Unexpected request: ${config.method} ${config.url}`);
|
||||
const requests = JSON.parse(config.data) as { resourceId: string; variant: string }[];
|
||||
const items = requests.map(({ resourceId, variant }) => {
|
||||
if (!["image-0", "image-1"].includes(resourceId)) throw new Error(`Unexpected resource: ${resourceId}`);
|
||||
return {
|
||||
resourceId,
|
||||
access: {
|
||||
resourceId,
|
||||
requestedVariant: variant,
|
||||
actualVariant: variant,
|
||||
url: `/api/resources/${resourceId}/content`,
|
||||
delivery: "platform-local",
|
||||
issuedAt: new Date().toISOString(),
|
||||
refreshAt: new Date(Date.now() + 60000).toISOString(),
|
||||
revision: "test",
|
||||
},
|
||||
};
|
||||
});
|
||||
return { config, headers: {}, status: 200, statusText: "OK", data: { code: 0, data: { items }, msg: "ok" } };
|
||||
};
|
||||
let unregister: (() => void) | undefined;
|
||||
try {
|
||||
setActiveUserScope("canvas-batch-commit-race");
|
||||
@@ -407,30 +430,58 @@ async function runCanvasBatchCommitRace() {
|
||||
const { parseCanvasStorageDocument } = await import("../../src/lib/canvas/canvas-storage-revision");
|
||||
const { CanvasNodeType } = await import("../../src/types/canvas");
|
||||
const projectId = useCanvasStore.getState().createProject("batch race");
|
||||
const nodes = Array.from({ length: 5 }, (_, index) => ({ id: `node-${index}`, type: CanvasNodeType.Image, title: `image-${index}`, position: { x: index * 400, y: 0 }, width: 320, height: 240, metadata: { taskId: `task-${index}`, status: index < 2 ? "loading" as const : "error" as const } }));
|
||||
const nodes = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `node-${index}`,
|
||||
type: CanvasNodeType.Image,
|
||||
title: `image-${index}`,
|
||||
position: { x: index * 400, y: 0 },
|
||||
width: 320,
|
||||
height: 240,
|
||||
metadata: { taskId: `task-${index}`, status: index < 2 ? ("loading" as const) : ("error" as const) },
|
||||
}));
|
||||
const ref: { current: import("../../src/types/canvas").CanvasNodeData[] } = { current: nodes };
|
||||
const setNodes = createCanvasStateWriter(ref, () => {});
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes });
|
||||
await flushCanvasStorePersistence();
|
||||
useAssetStore.setState({ assets: [0, 1].map((index) => ({ id: `asset-${index}`, kind: "image" as const, title: "generated", coverUrl: "/image.png", tags: [], createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:00Z", data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" } })) });
|
||||
useAssetStore.setState({
|
||||
assets: [0, 1].map((index) => ({
|
||||
id: `asset-${index}`,
|
||||
kind: "image" as const,
|
||||
title: "generated",
|
||||
coverUrl: "/image.png",
|
||||
tags: [],
|
||||
createdAt: "2026-09-21T00:00:00Z",
|
||||
updatedAt: "2026-09-21T00:00:00Z",
|
||||
data: { dataUrl: "/image.png", storageKey: `resource:image-${index}`, width: 640, height: 480, bytes: 100, mimeType: "image/png" },
|
||||
})),
|
||||
});
|
||||
unregister = registerCanvasGenerationLiveProject({ scope: getActiveUserScope(), projectId, adapter: { read: () => ({ nodes: ref.current, connections: [], chatSessions: [], activeChatId: null }), write: (state) => setNodes(state.nodes) } });
|
||||
let edited = false;
|
||||
harness.hooks.onSet = (storeName, _key, value) => {
|
||||
if (edited || storeName !== "app_state" || typeof value !== "string" || !value.includes("attach:task-")) return;
|
||||
edited = true;
|
||||
setNodes((current) => [...current.map((node) => node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node), { ...nodes[0], id: "new-during-save", metadata: {} }]);
|
||||
setNodes((current) => [...current.map((node) => (node.id === "node-0" ? { ...node, title: "用户改名", position: { x: 700, y: 500 } } : node)), { ...nodes[0], id: "new-during-save", metadata: {} }]);
|
||||
};
|
||||
await Promise.all([0, 1].map((index) => applyCanvasGenerationTaskNodeEffect({
|
||||
projectId, nodeId: `node-${index}`, nodesRef: ref, setNodes,
|
||||
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
|
||||
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` }, effectKey: `attach:task-${index}:0`,
|
||||
})));
|
||||
await Promise.all(
|
||||
[0, 1].map((index) =>
|
||||
applyCanvasGenerationTaskNodeEffect({
|
||||
projectId,
|
||||
nodeId: `node-${index}`,
|
||||
nodesRef: ref,
|
||||
setNodes,
|
||||
task: { id: `task-${index}`, projectId, type: "canvas_image", status: "succeeded", prompt: "test", attempts: 1, createdAt: "2026-09-21T00:00:00Z", updatedAt: "2026-09-21T00:00:01Z", resultJson: "{}" },
|
||||
output: { outputIndex: 0, mediaType: "image", materializedAssetId: `asset-${index}` },
|
||||
effectKey: `attach:task-${index}:0`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes: ref.current });
|
||||
await flushCanvasStorePersistence();
|
||||
const stored = parseCanvasStorageDocument(await localForageStorageForScope(getActiveUserScope()).getItem(CANVAS_STORE_KEY), []);
|
||||
return { edited, live: ref.current, restored: stored.state.projects.find((project) => project.id === projectId)?.nodes };
|
||||
} finally {
|
||||
unregister?.();
|
||||
apiClient.defaults.adapter = originalAdapter;
|
||||
setActiveUserScope(previousScope);
|
||||
harness.restore();
|
||||
}
|
||||
|
||||
@@ -88,14 +88,16 @@ test("menu surfaces are explicitly scoped and old account inner overrides are re
|
||||
expect(css).toContain("box-shadow: none !important");
|
||||
expect(css).not.toContain(".ant-modal");
|
||||
expect(globals).not.toContain(".workspace-account-popover .ant-popover-inner");
|
||||
expect(globals).toContain(".ant-select:not(.ant-select-open):has(input:focus-visible)");
|
||||
expect(globals).toContain(".ant-select-dropdown, .ant-dropdown-menu) {\n border: 0 !important;");
|
||||
expect(globals).toContain('.app-unified-select[data-input-modality="keyboard"]:not(.ant-select-open)');
|
||||
expect(globals).toMatch(/:where\(\.ant-select-dropdown, \.ant-dropdown-menu,[^)]*\)\s*\{\s*border: 0 !important;/);
|
||||
expect(globals).toContain("body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-cascader-menus, .ant-mentions-dropdown) {\n border: 0 !important;");
|
||||
});
|
||||
|
||||
test("shared single-select popup uses a borderless surface instead of a bright focus frame", () => {
|
||||
const select = readFileSync(new URL("../src/components/ui/base/select/select.tsx", import.meta.url), "utf8");
|
||||
expect(select).toContain("rounded-[var(--r-lg)] border-0 bg-surface-strong");
|
||||
expect(select).toContain("focus-visible:ring-1 focus-visible:ring-[var(--control-selected-border)]");
|
||||
expect(select).not.toContain('setPopoverWidth(width + 2)');
|
||||
expect(select).toContain("<AntSelect");
|
||||
expect(select).toContain('variant={variant ?? "filled"}');
|
||||
expect(select).toContain("data-input-modality={inputModality}");
|
||||
expect(select).toContain('setInputModality("keyboard")');
|
||||
expect(select).not.toContain("setPopoverWidth(width + 2)");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user