fix(release): 发布回归 - 修复资源引用保护并校准验证合同
This commit is contained in:
1 parent
04f49768cd
commit
037b288668
19 files changed
+363
-105
No files matched your search
@@ -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