mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(chat): 封面晚到时把产物卡换成快照,别把整个会话留在活 iframe 上
规格 §4.1 写死了:HTML / prototype / **slide** / document 的卡面只渲染 `<img>` 快照,不再挂该文件的 iframe。用户看到 deck 是活的,不是因为快照没做, 而是**输了 466 毫秒**。 真机数据:打包版里 `opendesign-seed-pitch.html` 的 `capture_state` 就是 `ready`、缩略图字节也在。但 ref 落库 17:13:05、封面 ready 17:13:06 —— 间隔 616ms,而客户端只在 run 终止后 **150ms** 重拉一次(`scheduleConversationMessage Refresh`),之后再也不拉,`cover.ts` 也不推任何东西。于是卡片在整个会话里停在 §6.4 的降级支上,直到整页刷新。 这正是规格第 505 行规定却从未实现的那半句:「pending thumbnail 不出 placeholder,直接走降级支;**后台 ready 后消息投影更新**」。 封面**故意不 await**(「一轮对话不该为了一张缩略图多等几秒」),所以补的是推送 而不是等待:daemon 在 `attachChatArtifactThumbnail` 之后发一个**瘦事件** (只有 projectId / conversationId / messageId,不带 refs),web 收到后复用现有 重拉。让 `listMessages` 仍然是「ref 是什么」的唯一权威 —— 事件重复或丢失最多 多一次 fetch,不会画错卡。降级支原样保留。 红测钉的是**时序**,不是分支:既有用例全都直接把 `artifactRefs` 当 prop 注入, 所以分支一直是对的、时序从没被测过。daemon 侧刻意在 run 已终止之后才订阅 (真实用户处境),先断言此刻 ref 还不是 ready 再放行渲染;web 侧先断言是 iframe 且没有 img,投递事件后断言换成 img 且 iframe 消失,另加一条反向控制 ——别的 projectId 的事件不得触发重拉,否则「见事件就重拉」也能蒙混过关。 顺手拦下一个真 bug:`projectEventToAgentEvent` 是一条**结尾没有守卫的收窄链**, 排除已知类型后直接假定剩下的是 `live_artifact_refresh` 并读 `evt.phase`。新事件 加进联合后会掉进去,变成一张 phase 为 undefined 的假 live-artifact 卡。
This commit is contained in:
@@ -100,6 +100,21 @@ export interface FreezeChatArtifactCoversInput {
|
||||
* only tests that want a deterministic failure pass anything else.
|
||||
*/
|
||||
videoFrameExtractor?: ChatArtifactVideoFrameExtractor;
|
||||
/**
|
||||
* Announce that this ref's projection changed after the turn already ended.
|
||||
*
|
||||
* The render outlives the turn on purpose, so by the time a cover exists the
|
||||
* client has already done its one post-run re-read and is showing the
|
||||
* live-iframe degrade branch. Something has to tell it to look again — spec
|
||||
* line 505, "后台 ready 后消息投影更新".
|
||||
*
|
||||
* A callback rather than an import: this module has no business knowing what
|
||||
* an SSE sink is, and the same indifference is what lets the tests observe
|
||||
* the render without standing up a transport. Fired only for a cover that
|
||||
* actually landed — a failure changes no projection, so announcing one would
|
||||
* buy the client a pointless fetch.
|
||||
*/
|
||||
onRefsChanged?: (row: MessageArtifactRow) => void;
|
||||
}
|
||||
|
||||
export interface FreezeChatArtifactCoversReport {
|
||||
@@ -173,7 +188,7 @@ export async function freezeAndRenderChatArtifactCovers(
|
||||
// Serial: the desktop renderer is one Electron window behind one IPC
|
||||
// socket, so firing these in parallel would only queue them somewhere less
|
||||
// observable.
|
||||
void renderCoversSequentially(deps, input.renderer, pending);
|
||||
void renderCoversSequentially(deps, input.renderer, pending, input.onRefsChanged);
|
||||
}
|
||||
return report;
|
||||
}
|
||||
@@ -357,10 +372,11 @@ async function renderCoversSequentially(
|
||||
deps: ChatArtifactCaptureDeps,
|
||||
renderer: ChatArtifactCoverRenderer,
|
||||
pending: ReadonlyArray<{ row: MessageArtifactRow; render: DesktopExportArtifactInput }>,
|
||||
onRefsChanged: ((row: MessageArtifactRow) => void) | undefined,
|
||||
): Promise<void> {
|
||||
for (const item of pending) {
|
||||
try {
|
||||
await renderOneCover(deps, renderer, item.row, item.render);
|
||||
await renderOneCover(deps, renderer, item.row, item.render, onRefsChanged);
|
||||
} catch (err) {
|
||||
// A cover is never allowed to take anything else down with it: the turn
|
||||
// has already been reported as finished by the time this runs.
|
||||
@@ -379,6 +395,7 @@ async function renderOneCover(
|
||||
renderer: ChatArtifactCoverRenderer,
|
||||
row: MessageArtifactRow,
|
||||
render: DesktopExportArtifactInput,
|
||||
onRefsChanged: ((row: MessageArtifactRow) => void) | undefined,
|
||||
): Promise<void> {
|
||||
let result: DesktopExportArtifactResult;
|
||||
try {
|
||||
@@ -401,6 +418,14 @@ async function renderOneCover(
|
||||
bytes,
|
||||
mime: result.mime || 'image/png',
|
||||
});
|
||||
// The cover is durable now, so the client's re-read is guaranteed to find
|
||||
// it. Announcing before the attach would race the very fetch it invites.
|
||||
// Never allowed to fail the render it is reporting on.
|
||||
try {
|
||||
onRefsChanged?.(row);
|
||||
} catch (err) {
|
||||
logCoverFailure(row.labelAtCapture, err);
|
||||
}
|
||||
} finally {
|
||||
// The renderer's temp file is the daemon's to clean up — the same contract
|
||||
// the `od export` route follows.
|
||||
|
||||
@@ -11427,6 +11427,27 @@ export async function startServer({
|
||||
projectRoot: outcome.projectRoot,
|
||||
rows: captured.rows,
|
||||
renderer: desktopArtifactExporter,
|
||||
/*
|
||||
* The cover outlives the turn by design, so it lands into a message
|
||||
* the client already filed as done — after the single post-run
|
||||
* re-read (`ProjectView.scheduleConversationMessageRefresh`, 150ms)
|
||||
* has been and gone. Without this nudge the card keeps the
|
||||
* live-iframe degrade branch until a full reload, which is the
|
||||
* regression spec line 505 exists to forbid.
|
||||
*
|
||||
* Thin on purpose: it names the stale message and nothing else, so
|
||||
* `listMessages` stays the only thing that decides what a ref is.
|
||||
*/
|
||||
onRefsChanged: (row) => {
|
||||
if (!run.projectId || !run.conversationId) return;
|
||||
emitProjectEvent(run.projectId, {
|
||||
type: 'chat-artifact-refs-changed',
|
||||
projectId: run.projectId,
|
||||
conversationId: run.conversationId,
|
||||
messageId: row.messageId,
|
||||
at: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[chat-artifacts] run terminal capture failed', err);
|
||||
|
||||
@@ -87,6 +87,73 @@ async function waitFor<T>(
|
||||
return last;
|
||||
}
|
||||
|
||||
interface CapturedSseEvent {
|
||||
event: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `/api/projects/:id/events` as raw SSE frames.
|
||||
*
|
||||
* `EventSource` does not exist in this runtime and the daemon writes one frame
|
||||
* per `res.write` (`createSseResponse`), so a body reader split on the blank
|
||||
* line is both sufficient and closer to the wire than any client shim: it sees
|
||||
* the event NAME, which is the thing under test.
|
||||
*/
|
||||
async function openProjectEventStream(projectId: string): Promise<{
|
||||
events: CapturedSseEvent[];
|
||||
ready: Promise<void>;
|
||||
close: () => void;
|
||||
}> {
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(`${baseUrl}/api/projects/${projectId}/events`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(response.ok, 'the project event stream should accept the subscription').toBe(true);
|
||||
|
||||
const events: CapturedSseEvent[] = [];
|
||||
let markReady!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
markReady = resolve;
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffered = '';
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffered += decoder.decode(value, { stream: true });
|
||||
let boundary = buffered.indexOf('\n\n');
|
||||
while (boundary !== -1) {
|
||||
const frame = buffered.slice(0, boundary);
|
||||
buffered = buffered.slice(boundary + 2);
|
||||
const name = /^event:\s*(.*)$/m.exec(frame)?.[1]?.trim();
|
||||
if (name) {
|
||||
const raw = /^data:\s*(.*)$/m.exec(frame)?.[1];
|
||||
let data: any = null;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
// A frame we cannot parse is still a frame; keep the name.
|
||||
}
|
||||
events.push({ event: name, data });
|
||||
if (name === 'ready') markReady();
|
||||
}
|
||||
boundary = buffered.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Aborted by `close()` at the end of the test.
|
||||
}
|
||||
})();
|
||||
|
||||
return { events, ready, close: () => controller.abort() };
|
||||
}
|
||||
|
||||
function dataDir(): string {
|
||||
const dir = process.env.OD_DATA_DIR;
|
||||
if (!dir) throw new Error('OD_DATA_DIR is required for chat artifact cover tests');
|
||||
@@ -373,4 +440,83 @@ describe('run terminal HTML cover wiring', () => {
|
||||
const body = (await versions.json()) as { versions?: Array<{ id: string }> };
|
||||
expect((body.versions ?? []).map((v) => v.id)).toContain(html!.versionId);
|
||||
});
|
||||
|
||||
/**
|
||||
* 设计文档第 505 行:「pending thumbnail 不出 placeholder,直接走 §6.4 的降级支;
|
||||
* **后台 ready 后消息投影更新**」。
|
||||
*
|
||||
* 前半条早就成立(上面几条测的就是它),后半条一直是空的:封面**故意不 await**,
|
||||
* 它在终止帧之后几百毫秒才落库,而 daemon 落库时**一声不吭**。客户端在
|
||||
* run 终止后 150ms 拉一次(`ProjectView.scheduleConversationMessageRefresh`),
|
||||
* 那一拉必然早于封面,之后再也不拉 —— 于是卡片在整个会话里一直停在降级支的
|
||||
* live iframe 上,直到整页刷新。真机实测这一局输了 466 毫秒:ref 落库
|
||||
* 17:13:05,封面 ready 17:13:06。
|
||||
*
|
||||
* ── 这个量法能看见缺陷吗 ────────────────────────────────────────────────
|
||||
* 关键是**订阅发生在 run 已经终止之后**。这正是真实用户的处境:他盯着一条
|
||||
* 已经写完的消息。如果先订阅再跑,事件可能是被终止帧顺路带出来的,那就证不出
|
||||
* 「封面自己会说话」。所以这里先把 renderer 关在闸门后跑完一整轮,确认此刻
|
||||
* **还没有** ready 的 ref,再连上事件流,最后才放行渲染。
|
||||
*
|
||||
* 断言的是 wire 上的事件名和消息身份,不是某个内部函数被调用过 —— 后者在
|
||||
* daemon 不推送的今天也能被 mock 成绿的。
|
||||
*/
|
||||
it('tells the project stream when a cover lands after the turn already ended', async () => {
|
||||
exporterCalls = [];
|
||||
let release!: () => void;
|
||||
renderGate = new Promise<void>((r) => {
|
||||
release = r;
|
||||
});
|
||||
exporterResult = async () => {
|
||||
const out = join(await fsp.mkdtemp(join(tmpdir(), 'od-cover-out-')), 'cover.png');
|
||||
await fsp.writeFile(out, PNG_1X1);
|
||||
return { ok: true, path: out, mime: 'image/png', bytes: PNG_1X1.byteLength };
|
||||
};
|
||||
|
||||
const turn = await runTurnThatWritesHtml();
|
||||
|
||||
// The turn is terminal and the renderer is parked at the gate, so the card
|
||||
// the user is looking at right now has no cover — it is on the live-iframe
|
||||
// degrade branch. That is the moment this test is about.
|
||||
await waitFor(() => exporterCalls.length, (n) => n > 0, 8_000);
|
||||
const beforeRelease = refsFor(turn.projectId, turn.messageId);
|
||||
expect(
|
||||
beforeRelease.find((ref) => ref.label === 'index.html')?.snapshotState,
|
||||
'the cover must still be outstanding, otherwise this test proves nothing',
|
||||
).not.toBe('ready');
|
||||
|
||||
// The viewer connects AFTER the run finished, exactly like someone sitting
|
||||
// on a completed message.
|
||||
const stream = await openProjectEventStream(turn.projectId);
|
||||
try {
|
||||
await stream.ready;
|
||||
|
||||
release();
|
||||
|
||||
const signal = await waitFor(
|
||||
() => stream.events.find((evt) => evt.event === 'chat-artifact-refs-changed'),
|
||||
(evt) => Boolean(evt),
|
||||
8_000,
|
||||
);
|
||||
|
||||
expect(
|
||||
signal,
|
||||
'a cover that lands after the terminal frame must announce itself on the project stream',
|
||||
).toBeTruthy();
|
||||
// The payload has to name the message whose projection went stale, or the
|
||||
// client cannot know which conversation to re-read.
|
||||
expect(signal?.data?.projectId).toBe(turn.projectId);
|
||||
expect(signal?.data?.conversationId).toBe(turn.conversationId);
|
||||
expect(signal?.data?.messageId).toBe(turn.messageId);
|
||||
|
||||
// And the re-read the signal invites must actually return the cover.
|
||||
const refs = await waitFor(
|
||||
() => refsFor(turn.projectId, turn.messageId),
|
||||
(list) => list.some((ref) => ref.label === 'index.html' && ref.snapshotState === 'ready'),
|
||||
);
|
||||
expect(refs.find((ref) => ref.label === 'index.html')?.thumbnailUrl).toBeTruthy();
|
||||
} finally {
|
||||
stream.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { bindStreamVisibility } from '../lib/stream-visibility';
|
||||
import {
|
||||
COLLAB_PROJECT_INVALIDATION_EVENTS,
|
||||
PROJECT_CONTENT_TRANSFER_STATE_EVENT,
|
||||
type ChatArtifactRefsChangedSsePayload,
|
||||
type CollabProjectInvalidationSsePayload,
|
||||
type LiveArtifactRefreshSsePayload,
|
||||
type LiveArtifactSsePayload,
|
||||
@@ -35,11 +36,21 @@ export type ProjectLiveArtifactEvent = LiveArtifactSsePayload | LiveArtifactRefr
|
||||
// receipt — the event carries no body.
|
||||
export type ProjectCollabInvalidationEvent = CollabProjectInvalidationSsePayload;
|
||||
|
||||
/**
|
||||
* A finished message's artifact refs changed after its run's terminal frame.
|
||||
*
|
||||
* Same thin-invalidation shape as the collab events above — it names the stale
|
||||
* message and carries no refs, so the consumer re-reads the conversation rather
|
||||
* than trusting a projection assembled on the wire.
|
||||
*/
|
||||
export type ProjectChatArtifactRefsChangedEvent = ChatArtifactRefsChangedSsePayload;
|
||||
|
||||
export type ProjectEvent =
|
||||
| ProjectFileChangeEvent
|
||||
| ProjectConversationCreatedEvent
|
||||
| ProjectLiveArtifactEvent
|
||||
| ProjectCollabInvalidationEvent
|
||||
| ProjectChatArtifactRefsChangedEvent
|
||||
| ProjectContentTransferStateSsePayload;
|
||||
|
||||
export interface ProjectEventsConnectionOptions {
|
||||
@@ -201,6 +212,24 @@ export function createProjectEventsConnection(
|
||||
}
|
||||
});
|
||||
}
|
||||
// A cover finished rendering after its turn already ended. Thin signal:
|
||||
// it names the stale message, and the consumer re-reads the conversation.
|
||||
es.addEventListener('chat-artifact-refs-changed', (evt) => {
|
||||
try {
|
||||
const data = JSON.parse(
|
||||
(evt as MessageEvent).data,
|
||||
) as ProjectChatArtifactRefsChangedEvent;
|
||||
onChange(data);
|
||||
} catch (err) {
|
||||
if (
|
||||
typeof process !== 'undefined'
|
||||
&& process.env?.NODE_ENV === 'development'
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[project-events] malformed chat-artifact-refs-changed payload', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
es.addEventListener(PROJECT_CONTENT_TRANSFER_STATE_EVENT, (evt) => {
|
||||
try {
|
||||
// Thin invalidation only. The consumer must re-read exact-scoped
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* 封面**晚到**的那一局 —— `chat-artifact-versioning-design.md` 第 505 行:
|
||||
* 「pending thumbnail 不出 placeholder,直接走 §6.4 的降级支;
|
||||
* **后台 ready 后消息投影更新**」。
|
||||
*
|
||||
* 前半条一直是对的,后半条一直是空的。HTML 卡的静态封面**故意不 await**
|
||||
* (`chat-artifacts/cover.ts`:「一轮对话不该为了一张缩略图多等几秒」),
|
||||
* 它在终止帧之后几百毫秒才落库;而客户端在 run 终止后**只拉一次**
|
||||
* (`scheduleConversationMessageRefresh`,150ms),那一拉必然早于封面。
|
||||
* 真机实测这一局输了 466 毫秒:ref 落库 17:13:05,封面 ready 17:13:06。
|
||||
* 之后再也没有第二次拉取 —— 于是卡片在整个会话里停在降级支的 live iframe 上,
|
||||
* 直到整页刷新。用户看到的就是「12 页 deck 在卡里活着跑」。
|
||||
*
|
||||
* ── 这个量法能看见缺陷吗 ────────────────────────────────────────────────
|
||||
* 要害是**顺序**,不是「有 ready ref 就画 img」。后者在坏掉的构建上也是绿的
|
||||
* (`artifact-card-version-semantics.test.tsx` 已经覆盖了那条,注入 prop 即可)。
|
||||
* 所以这里必须先让卡真的落到 iframe 上,再放事件进来,最后才断言它换成了 img:
|
||||
*
|
||||
* ① 挂载时 `listMessages` 返回 `legacy_unavailable` —— 150ms 那一拉的真实返回;
|
||||
* ② 断言卡面是 `<iframe>`(降级支);
|
||||
* ③ 送进 `chat-artifact-refs-changed`,此时 `listMessages` 改口 `ready`;
|
||||
* ④ 断言卡面换成 `<img src=thumbnailUrl>`,且 iframe 消失。
|
||||
*
|
||||
* 去掉 ③ 那一步的处理分支,④ 必须重新变红 —— 已验(见本次改动说明)。
|
||||
*
|
||||
* ── 为什么 ChatPane 的替身里挂的是**真的** AssistantMessage ────────────
|
||||
* 被测的是 ProjectView 的**消息状态**会不会因为那个事件而更新。卡片自己怎么在
|
||||
* `ready` 和降级之间选,是 `FileOpsSummary` 的事,已另有覆盖。所以这里把替身做薄:
|
||||
* 只替掉 ChatPane 的版式,`message → messageArtifactRefs → FileOpsSummary →
|
||||
* ArtifactCard` 整条仍然是真的,ProjectView 交出什么样的 message,卡就照着画。
|
||||
* 如果这里连 AssistantMessage 一起 mock 掉,断言的就是夹具而不是产品。
|
||||
*
|
||||
* ── jsdom 的两个坑(不绕过就是假绿)────────────────────────────────────
|
||||
* 1. HTML 降级支要挂出 iframe 有两道前置:`useInView`(jsdom 没有
|
||||
* IntersectionObserver,直接判 true)和一次 `HEAD` 探测成功。默认 jsdom 里
|
||||
* `fetch('/api/...')` 会抛(相对 URL 无 origin),iframe 根本挂不出来 ——
|
||||
* 那样第 ② 步会**假绿**:它测的是网络在 jsdom 里失败了。所以下面把 `fetch`
|
||||
* 打桩成 200。
|
||||
* 2. jsdom **不做布局**(`scrollHeight`/`clientHeight` 恒为 0)也不加载样式表,
|
||||
* 所以规格 §12.3 那条「pending → ready 不改变卡外框高度、不打断用户滚动」
|
||||
* **在这里量不到**,本文件一个字都没有断言它。那一条只能真机验。
|
||||
*/
|
||||
|
||||
import { act, cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ProjectView } from '../../src/components/ProjectView';
|
||||
import { useProjectFileEvents, type ProjectEvent } from '../../src/providers/project-events';
|
||||
import type { ChatMessage } from '../../src/types';
|
||||
|
||||
const PROJECT_ID = 'project-1';
|
||||
const CONVERSATION_ID = 'conv-1';
|
||||
const MESSAGE_ID = 'msg-1';
|
||||
const ARTIFACT = 'opendesign-seed-pitch.html';
|
||||
const COVER_URL = `/api/projects/${PROJECT_ID}/chat-artifact-snapshots/snap-1/thumbnail`;
|
||||
const RAW_URL = `/api/projects/${PROJECT_ID}/raw/${ARTIFACT}`;
|
||||
|
||||
const listConversations = vi.fn();
|
||||
const listMessages = vi.fn();
|
||||
const fetchPreviewComments = vi.fn();
|
||||
const loadTabs = vi.fn();
|
||||
const fetchProjectFiles = vi.fn();
|
||||
const fetchLiveArtifacts = vi.fn();
|
||||
const fetchSkill = vi.fn();
|
||||
const fetchDesignSystem = vi.fn();
|
||||
const getTemplate = vi.fn();
|
||||
const fetchChatRunStatus = vi.fn();
|
||||
const listActiveChatRuns = vi.fn();
|
||||
const listProjectRuns = vi.fn();
|
||||
const reattachDaemonRun = vi.fn();
|
||||
const streamViaDaemon = vi.fn();
|
||||
const saveMessage = vi.fn();
|
||||
|
||||
vi.mock('../../src/i18n', () => ({
|
||||
useI18n: () => ({ locale: 'en', setLocale: () => undefined, t: (value: string) => value }),
|
||||
useT: () => ((value: string) => value),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/providers/anthropic', () => ({ streamMessage: vi.fn() }));
|
||||
|
||||
vi.mock('../../src/providers/daemon', () => ({
|
||||
GENERIC_DAEMON_DISCONNECT_CODE: 'GENERIC_DAEMON_DISCONNECT',
|
||||
GENERIC_DAEMON_DISCONNECT_MESSAGE: 'daemon stream disconnected before run completed',
|
||||
fetchChatRunStatus: (...args: unknown[]) => fetchChatRunStatus(...args),
|
||||
listActiveChatRuns: (...args: unknown[]) => listActiveChatRuns(...args),
|
||||
listProjectRuns: (...args: unknown[]) => listProjectRuns(...args),
|
||||
publishDaemonRunFinishedEvent: vi.fn(),
|
||||
reattachDaemonRun: (...args: unknown[]) => reattachDaemonRun(...args),
|
||||
streamViaDaemon: (...args: unknown[]) => streamViaDaemon(...args),
|
||||
}));
|
||||
|
||||
/*
|
||||
* 部分 mock:产物卡要用真的 `projectFileUrl` 去拼降级支那个 iframe 的 src ——
|
||||
* 那正是第 ② 步要断言的东西,替成假的就等于自己写了个答案。
|
||||
*/
|
||||
vi.mock('../../src/providers/registry', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
deletePreviewComment: vi.fn(),
|
||||
fetchPreviewComments: (...args: unknown[]) => fetchPreviewComments(...args),
|
||||
fetchDesignSystem: (...args: unknown[]) => fetchDesignSystem(...args),
|
||||
fetchProjectDesignSystemPackageAudit: vi.fn(async () => null),
|
||||
fetchLiveArtifacts: (...args: unknown[]) => fetchLiveArtifacts(...args),
|
||||
fetchProjectFiles: (...args: unknown[]) => fetchProjectFiles(...args),
|
||||
fetchSkill: (...args: unknown[]) => fetchSkill(...args),
|
||||
patchPreviewCommentStatus: vi.fn(),
|
||||
upsertPreviewComment: vi.fn(),
|
||||
writeProjectTextFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/providers/project-events', () => ({ useProjectFileEvents: vi.fn() }));
|
||||
vi.mock('../../src/router', () => ({ navigate: vi.fn() }));
|
||||
|
||||
vi.mock('../../src/state/projects', () => ({
|
||||
cacheTabsLocally: vi.fn((projectId: string, tabs: unknown) => ({ projectId, tabs })),
|
||||
createConversation: vi.fn(),
|
||||
deleteConversation: vi.fn(),
|
||||
getTemplate: (...args: unknown[]) => getTemplate(...args),
|
||||
listConversations: (...args: unknown[]) => listConversations(...args),
|
||||
listMessages: (...args: unknown[]) => listMessages(...args),
|
||||
loadTabs: (...args: unknown[]) => loadTabs(...args),
|
||||
patchConversation: vi.fn(),
|
||||
patchProject: vi.fn(),
|
||||
persistTabsToDaemonNow: vi.fn(),
|
||||
saveMessage: (...args: unknown[]) => saveMessage(...args),
|
||||
saveTabs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/components/AppChromeHeader', () => ({ AppChromeHeader: () => null }));
|
||||
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
|
||||
vi.mock('../../src/components/Loading', () => ({ CenteredLoader: () => null }));
|
||||
vi.mock('../../src/components/FileWorkspace', () => ({
|
||||
DESIGN_SYSTEM_TAB: '__design_system__',
|
||||
FileWorkspace: () => null,
|
||||
}));
|
||||
|
||||
/*
|
||||
* 薄替身:只替掉 ChatPane 的版式,产物卡那条链路留真的。ProjectView 手里的
|
||||
* message 是什么样,卡就画成什么样 —— 这正是本文件要观察的东西。
|
||||
*/
|
||||
vi.mock('../../src/components/ChatPane', async () => {
|
||||
const { AssistantMessage } = await import('../../src/components/AssistantMessage');
|
||||
return {
|
||||
ChatPane: ({ messages }: { messages: ChatMessage[] }) => (
|
||||
<>
|
||||
{messages
|
||||
.filter((message) => message.role === 'assistant')
|
||||
.map((message) => (
|
||||
<AssistantMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
streaming={false}
|
||||
projectId={PROJECT_ID}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
/** 这一轮产出的 HTML。有 `producedFiles` 才会出产物卡。 */
|
||||
function producedHtml() {
|
||||
return [
|
||||
{
|
||||
name: ARTIFACT,
|
||||
path: ARTIFACT,
|
||||
size: 49_711,
|
||||
mtime: Date.now(),
|
||||
kind: 'html' as const,
|
||||
mime: 'text/html',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条**已经结束**的轮次。
|
||||
*
|
||||
* `snapshotState` 由调用方给:挂载那次是 `legacy_unavailable`(150ms 那一拉的
|
||||
* 真实返回 —— 封面还没渲染完,ref 上根本没有 snapshot 行),事件之后才是 `ready`。
|
||||
*/
|
||||
function finishedTurn(ref: Record<string, unknown>): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
id: MESSAGE_ID,
|
||||
role: 'assistant',
|
||||
agentId: 'agent-1',
|
||||
content: '做好了',
|
||||
createdAt: Date.now(),
|
||||
runId: 'run-1',
|
||||
runStatus: 'succeeded',
|
||||
producedFiles: producedHtml(),
|
||||
artifactRefs: [ref],
|
||||
} as unknown as ChatMessage,
|
||||
];
|
||||
}
|
||||
|
||||
const pendingRef = {
|
||||
id: 'ref-1',
|
||||
label: ARTIFACT,
|
||||
kind: 'html',
|
||||
displayPolicy: 'latest_with_static_preview',
|
||||
workspaceArtifactId: 'wa-1',
|
||||
snapshotState: 'legacy_unavailable',
|
||||
};
|
||||
|
||||
const readyRef = {
|
||||
...pendingRef,
|
||||
snapshotId: 'snap-1',
|
||||
thumbnailUrl: COVER_URL,
|
||||
snapshotState: 'ready',
|
||||
};
|
||||
|
||||
function renderProjectView() {
|
||||
const project = {
|
||||
id: PROJECT_ID,
|
||||
name: 'OpenDesign 种子轮路演 Deck',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
} as never;
|
||||
return render(
|
||||
<ProjectView
|
||||
project={project}
|
||||
initialProjectDetail={{ project, resolvedDir: null }}
|
||||
routeFileName={null}
|
||||
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
|
||||
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
|
||||
skills={[]}
|
||||
designTemplates={[]}
|
||||
designSystems={[]}
|
||||
daemonLive
|
||||
onModeChange={() => {}}
|
||||
onAgentChange={() => {}}
|
||||
onAgentModelChange={() => {}}
|
||||
onRefreshAgents={() => {}}
|
||||
onOpenSettings={() => {}}
|
||||
onBack={() => {}}
|
||||
onClearPendingPrompt={() => {}}
|
||||
onTouchProject={() => {}}
|
||||
onProjectChange={() => {}}
|
||||
onProjectsRefresh={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
/** 让 HTML 降级支那道 HEAD 探测**通过** —— 见文件头的假绿说明。 */
|
||||
function stubHeadProbeAsReachable() {
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async () => new Response('', { status: 200 }),
|
||||
);
|
||||
}
|
||||
|
||||
function primeDaemon() {
|
||||
listConversations.mockResolvedValue([{ id: CONVERSATION_ID, title: 'Conversation' }]);
|
||||
listMessages.mockResolvedValue(finishedTurn(pendingRef));
|
||||
fetchPreviewComments.mockResolvedValue([]);
|
||||
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
|
||||
fetchProjectFiles.mockResolvedValue(producedHtml());
|
||||
fetchLiveArtifacts.mockResolvedValue([]);
|
||||
fetchSkill.mockResolvedValue(null);
|
||||
fetchDesignSystem.mockResolvedValue(null);
|
||||
getTemplate.mockResolvedValue(null);
|
||||
listActiveChatRuns.mockResolvedValue([]);
|
||||
listProjectRuns.mockResolvedValue([]);
|
||||
fetchChatRunStatus.mockResolvedValue(null);
|
||||
}
|
||||
|
||||
/** ProjectView 挂给 `useProjectFileEvents` 的那个 handler(第 3 个实参)。 */
|
||||
function projectEventHandler(): (evt: ProjectEvent) => void {
|
||||
const calls = vi.mocked(useProjectFileEvents).mock.calls;
|
||||
const handler = calls.at(-1)?.[2];
|
||||
if (typeof handler !== 'function') {
|
||||
throw new Error('ProjectView never subscribed to the project event stream');
|
||||
}
|
||||
return handler as (evt: ProjectEvent) => void;
|
||||
}
|
||||
|
||||
const card = () =>
|
||||
document.querySelector<HTMLElement>(`[data-testid="artifact-card-${ARTIFACT}"]`);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
primeDaemon();
|
||||
stubHeadProbeAsReachable();
|
||||
});
|
||||
|
||||
describe('封面在终止帧之后才落地时,卡面必须自己换成快照', () => {
|
||||
it('先降级成 live iframe,收到 chat-artifact-refs-changed 后换成 <img> 快照', async () => {
|
||||
renderProjectView();
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalled());
|
||||
|
||||
// ① + ②:150ms 那一拉返回的就是「没有 ready 快照」,卡落在降级支上。
|
||||
const frame = await waitFor(() => {
|
||||
const found = card()?.querySelector<HTMLIFrameElement>('iframe');
|
||||
expect(found, '没有当轮快照时,卡面应当是 live iframe(§6.4 降级支)').toBeTruthy();
|
||||
return found!;
|
||||
}, { timeout: 2_000 });
|
||||
expect(frame.getAttribute('src')).toBe(RAW_URL);
|
||||
expect(
|
||||
card()?.querySelector('img.artifact-card-media'),
|
||||
'快照还没落地就画 img,卡面会是一张碎图',
|
||||
).toBeNull();
|
||||
|
||||
// ③:封面这时候才渲染完落库,daemon 推事件,重拉才能拿到 ready。
|
||||
listMessages.mockResolvedValue(finishedTurn(readyRef));
|
||||
act(() => {
|
||||
projectEventHandler()({
|
||||
type: 'chat-artifact-refs-changed',
|
||||
projectId: PROJECT_ID,
|
||||
conversationId: CONVERSATION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
} as ProjectEvent);
|
||||
});
|
||||
|
||||
// ④:卡面自己换成当轮静态首屏截图,不需要整页刷新。
|
||||
const shot = await waitFor(() => {
|
||||
const found = card()?.querySelector<HTMLImageElement>('img.artifact-card-media');
|
||||
expect(
|
||||
found,
|
||||
'封面已经 ready 却还挂着 live iframe:整条会话里 deck 会一直活着跑',
|
||||
).toBeTruthy();
|
||||
return found!;
|
||||
}, { timeout: 2_000 });
|
||||
expect(shot.getAttribute('src')).toBe(COVER_URL);
|
||||
expect(
|
||||
card()?.querySelector('iframe'),
|
||||
'换成快照后还留着 iframe = 两份卡面同时在,活的那份还会跟着 latest 漂',
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('别的项目的同名事件不该惊动这条会话', async () => {
|
||||
renderProjectView();
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalled());
|
||||
await waitFor(() => expect(card()?.querySelector('iframe')).toBeTruthy(), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
const before = listMessages.mock.calls.length;
|
||||
act(() => {
|
||||
projectEventHandler()({
|
||||
type: 'chat-artifact-refs-changed',
|
||||
projectId: 'some-other-project',
|
||||
conversationId: CONVERSATION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
} as ProjectEvent);
|
||||
});
|
||||
|
||||
/*
|
||||
* 反向对照。少了它,一个「收到任何事件就无脑重拉」的实现也能把上面那条弄绿,
|
||||
* 而那种实现会让每个开着的项目互相拉对方的会话。
|
||||
*/
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
expect(listMessages.mock.calls.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,34 @@ export interface ProjectConversationCreatedSsePayload {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emitted by the daemon on `/api/projects/:id/events` when a finished message's
|
||||
* artifact refs changed AFTER its run's terminal frame already went out.
|
||||
*
|
||||
* The one producer today is the HTML card's static cover. Freezing the renderer
|
||||
* input is awaited at the terminal chokepoint, but the render itself is
|
||||
* deliberately not (`chat-artifacts/cover.ts`) — a turn should not stay open for
|
||||
* a thumbnail. So the cover lands a few hundred milliseconds into a message the
|
||||
* client already considers done, and the client's one post-run re-read has
|
||||
* already been and gone. Without this signal the card keeps the live-iframe
|
||||
* degrade branch for the rest of the session, which is precisely what
|
||||
* `chat-artifact-versioning-design.md` line 505 rules out: "pending thumbnail
|
||||
* 不出 placeholder,直接走 §6.4 的降级支;后台 ready 后消息投影更新".
|
||||
*
|
||||
* Thin by design, like the collab invalidation events it sits beside: it names
|
||||
* the message whose projection went stale and carries no refs of its own. The
|
||||
* consumer re-reads the conversation, so one authority (`listMessages`) keeps
|
||||
* deciding what a ref actually is, and a dropped or duplicated event costs a
|
||||
* redundant fetch rather than a wrong card.
|
||||
*/
|
||||
export interface ChatArtifactRefsChangedSsePayload {
|
||||
type: 'chat-artifact-refs-changed';
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
at?: number;
|
||||
}
|
||||
|
||||
export const CHAT_SSE_PROTOCOL_VERSION = 1;
|
||||
|
||||
export interface ChatSseStartPayload {
|
||||
|
||||
Reference in New Issue
Block a user