From 331609e6abbe8dc54fd93a15a1fcfc09f949820a Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:05:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=20=E5=8F=91=E5=B8=83=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=20-=20=E4=BF=AE=E5=A4=8D=E8=B5=84=E6=BA=90=E5=BC=95?= =?UTF-8?q?=E7=94=A8=E4=BF=9D=E6=8A=A4=E5=B9=B6=E6=A0=A1=E5=87=86=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin_channel_test.go | 5 +- .../app/provider_gemini_cache_test.go | 22 ++- backend/internal/app/runtime_policy_test.go | 7 +- .../internal/database/prefixed_ids_test.go | 91 ++++++++++++ .../internal/repository/resource_reference.go | 12 +- .../content/docs/backend/backend-database.mdx | 10 +- docs/content/docs/progress/pending-test.mdx | 7 + web/test/agent-interjection.test.ts | 4 +- web/test/agent-tool-presentation.test.ts | 5 +- web/test/app-theme.test.ts | 12 +- web/test/appearance-bootstrap.test.ts | 3 +- web/test/application-bootstrap.test.mjs | 9 +- web/test/asset-batch-delete.test.ts | 133 +++++++++++++----- web/test/canvas-media-performance.test.ts | 40 +++--- .../canvas-node-title-interaction.test.ts | 16 +-- .../canvas-resource-mention-editor.test.ts | 5 +- web/test/create-canvas-handoff.test.ts | 8 +- .../generation-storage-consistency.worker.ts | 67 +++++++-- web/test/workspace-menu-density.test.tsx | 12 +- 19 files changed, 363 insertions(+), 105 deletions(-) create mode 100644 backend/internal/database/prefixed_ids_test.go diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index b48566999..31f9fa8dd 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -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) } diff --git a/backend/internal/app/provider_gemini_cache_test.go b/backend/internal/app/provider_gemini_cache_test.go index b9b78e75f..3c08deea5 100644 --- a/backend/internal/app/provider_gemini_cache_test.go +++ b/backend/internal/app/provider_gemini_cache_test.go @@ -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 { diff --git a/backend/internal/app/runtime_policy_test.go b/backend/internal/app/runtime_policy_test.go index 2b63c2fc2..0cdecd98b 100644 --- a/backend/internal/app/runtime_policy_test.go +++ b/backend/internal/app/runtime_policy_test.go @@ -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", diff --git a/backend/internal/database/prefixed_ids_test.go b/backend/internal/database/prefixed_ids_test.go new file mode 100644 index 000000000..11307d9c9 --- /dev/null +++ b/backend/internal/database/prefixed_ids_test.go @@ -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) + } +} diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index d5854aac4..940b39ea2 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -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 diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 5939b05d1..bda833e8e 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -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` 的版本与校验和记录。 ### 生成成功后的作品保存恢复 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index bf79327fd..c7f1a95a0 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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`。标题未配置时跟随外观品牌名生成;正文按空行分段渲染,留空时注册页提示待补充。注册开关单独切换不会覆盖协议(后端按可选指针字段合并)。 diff --git a/web/test/agent-interjection.test.ts b/web/test/agent-interjection.test.ts index 3e6162952..cc49593e9 100644 --- a/web/test/agent-interjection.test.ts +++ b/web/test/agent-interjection.test.ts @@ -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(/ { 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 = "技能未在本轮启用,或参考文件未包含在固定快照中"; diff --git a/web/test/app-theme.test.ts b/web/test/app-theme.test.ts index 378145fd2..32c7ec7fc 100644 --- a/web/test/app-theme.test.ts +++ b/web/test/app-theme.test.ts @@ -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"); }); }); diff --git a/web/test/appearance-bootstrap.test.ts b/web/test/appearance-bootstrap.test.ts index 7ace0c2ba..181b3d46d 100644 --- a/web/test/appearance-bootstrap.test.ts +++ b/web/test/appearance-bootstrap.test.ts @@ -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"); diff --git a/web/test/application-bootstrap.test.mjs b/web/test/application-bootstrap.test.mjs index a123092c3..72c24a736 100644 --- a/web/test/application-bootstrap.test.mjs +++ b/web/test/application-bootstrap.test.mjs @@ -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(); }); } diff --git a/web/test/asset-batch-delete.test.ts b/web/test/asset-batch-delete.test.ts index e7a2726f6..5bfa3519b 100644 --- a/web/test/asset-batch-delete.test.ts +++ b/web/test/asset-batch-delete.test.ts @@ -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(promise: Promise): Promise { let timer: ReturnType | undefined; try { - return await Promise.race([promise, new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("删除或刷新链路未结束")), 1000); - })]); + return await Promise.race([ + promise, + new Promise((_, 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(); + const indexed = new Map(); + 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((resolve) => { releaseRefresh = resolve; }); + const refreshGate = new Promise((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((resolve) => { - stops.push(observer.subscribe((result) => { - if (result.fetchStatus === "idle" && (result.isError || result.data?.total === 1)) resolve(); - })); - })); + const refreshed = observers.map( + (observer) => + new Promise((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"])); diff --git a/web/test/canvas-media-performance.test.ts b/web/test/canvas-media-performance.test.ts index a101aec6e..43297c5d1 100644 --- a/web/test/canvas-media-performance.test.ts +++ b/web/test/canvas-media-performance.test.ts @@ -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(" setPassiveVideoReady(true)}"); + expect(inactivePreviewSource).toContain("hasPersistedPreview || !nearViewport"); + expect(inactivePreviewSource).toContain("URL.revokeObjectURL(localPreviewUrlRef.current)"); + expect(inactivePreviewSource).not.toContain(" { @@ -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", () => { diff --git a/web/test/canvas-node-title-interaction.test.ts b/web/test/canvas-node-title-interaction.test.ts index abdb4299c..9c4511211 100644 --- a/web/test/canvas-node-title-interaction.test.ts +++ b/web/test/canvas-node-title-interaction.test.ts @@ -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] || ""; diff --git a/web/test/canvas-resource-mention-editor.test.ts b/web/test/canvas-resource-mention-editor.test.ts index e4b87c501..f428d0fc6 100644 --- a/web/test/canvas-resource-mention-editor.test.ts +++ b/web/test/canvas-resource-mention-editor.test.ts @@ -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"); }); diff --git a/web/test/create-canvas-handoff.test.ts b/web/test/create-canvas-handoff.test.ts index c96a45cdb..86de4d8d4 100644 --- a/web/test/create-canvas-handoff.test.ts +++ b/web/test/create-canvas-handoff.test.ts @@ -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"'); diff --git a/web/test/helpers/generation-storage-consistency.worker.ts b/web/test/helpers/generation-storage-consistency.worker.ts index eff2b1b20..1d08e2e21 100644 --- a/web/test/helpers/generation-storage-consistency.worker.ts +++ b/web/test/helpers/generation-storage-consistency.worker.ts @@ -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(); } diff --git a/web/test/workspace-menu-density.test.tsx b/web/test/workspace-menu-density.test.tsx index 202853bed..0137be7fb 100644 --- a/web/test/workspace-menu-density.test.tsx +++ b/web/test/workspace-menu-density.test.tsx @@ -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("