fix(release): 发布回归 - 修复资源引用保护并校准验证合同

This commit is contained in:
ddcat666 committed 2026-09-26 17:05:36 +08:00
1 parent 04f49768cd
commit 037b288668
19 files changed
+363 -105

No files matched your search

+3 -1
View File
@@ -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}");
+3 -2
View File
@@ -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 = "技能未在本轮启用,或参考文件未包含在固定快照中";
+9 -3
View File
@@ -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");
});
});
+2 -1
View File
@@ -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");
+5 -4
View File
@@ -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();
});
}
+101 -32
View File
@@ -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"]));
+20 -20
View File
@@ -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");
});
+2 -6
View File
@@ -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();
}
+7 -5
View File
@@ -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)");
});