411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { WorksSquareDesignWorkspace } from '@electron/image-workspace/works-square-workspace';
|
|
import { DesignWorkspaceModuleError } from '@electron/image-workspace/module';
|
|
import { getValidWorksSquareAccessToken } from '@electron/services/works-square-session';
|
|
import { designValuesFixture } from '../fixtures/design-workspace-v2';
|
|
|
|
vi.mock('@electron/services/works-square-session', () => ({
|
|
getValidWorksSquareAccessToken: vi.fn(),
|
|
}));
|
|
|
|
const getTokenMock = vi.mocked(getValidWorksSquareAccessToken);
|
|
|
|
function jsonResponse(payload: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const serverSummary = {
|
|
workspace_id: 'workspace-1',
|
|
client_workspace_id: 'client-workspace-1',
|
|
title: '咖啡机新品主视觉',
|
|
direction_id: 'direction-1',
|
|
session_id: 'session-1',
|
|
direction_revision: 4,
|
|
specification_revision: 3,
|
|
workspace_view_revision: 6,
|
|
updated_at: '2026-08-30T10:00:00.000Z',
|
|
};
|
|
|
|
const serverForm = {
|
|
workspace_id: 'workspace-1',
|
|
direction_id: 'direction-1',
|
|
direction_revision: 4,
|
|
raw_turn_sequence: 1,
|
|
specification_revision: 3,
|
|
workspace_view_revision: 6,
|
|
specification_revision_id: 'specification-revision-3',
|
|
specification_digest: 'a'.repeat(64),
|
|
specification: {
|
|
schema_version: 1,
|
|
values: designValuesFixture,
|
|
field_decisions: {},
|
|
},
|
|
decision_prompts: [],
|
|
active_quotes: [],
|
|
recent_change_set: { interaction_id: 'interaction-1', changes: [] },
|
|
};
|
|
|
|
const serverWorkspace = {
|
|
workspace: serverSummary,
|
|
form: serverForm,
|
|
turns: [{
|
|
turn_id: 'turn-1',
|
|
raw_turn_sequence: 1,
|
|
user_message: '做一张新品主视觉',
|
|
assistant_message: '已写入设计表单。',
|
|
}],
|
|
tasks: [],
|
|
assets: [],
|
|
};
|
|
|
|
type MockSocket = {
|
|
readyState: number;
|
|
onopen: (() => void) | null;
|
|
onmessage: ((event: { data: unknown }) => void) | null;
|
|
onerror: ((event: unknown) => void) | null;
|
|
onclose: ((event: { code: number; reason: string }) => void) | null;
|
|
send: ReturnType<typeof vi.fn>;
|
|
close: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
function createSocket(): MockSocket {
|
|
const socket: MockSocket = {
|
|
readyState: 1,
|
|
onopen: null,
|
|
onmessage: null,
|
|
onerror: null,
|
|
onclose: null,
|
|
send: vi.fn(),
|
|
close: vi.fn((code = 1000, reason = '') => socket.onclose?.({ code, reason })),
|
|
};
|
|
return socket;
|
|
}
|
|
|
|
describe('Works Square V2 Design Workspace adapter', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getTokenMock.mockResolvedValue('access-token');
|
|
});
|
|
|
|
it('maps the V2 bootstrap and canonical Living Form detail', async () => {
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/api/design/capabilities')) {
|
|
return jsonResponse({ conversation: true, generation: true, image: true, video: true });
|
|
}
|
|
if (url.includes('/api/design/workspaces?')) return jsonResponse([serverSummary]);
|
|
if (url.endsWith('/api/design/workspaces/workspace-1')) return jsonResponse(serverWorkspace);
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
await expect(module.bootstrap()).resolves.toMatchObject({
|
|
workspaces: [{
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
directionRevision: 4,
|
|
specificationRevision: 3,
|
|
}],
|
|
});
|
|
await expect(module.getWorkspace('workspace-1')).resolves.toMatchObject({
|
|
workspace: { title: '咖啡机新品主视觉' },
|
|
form: {
|
|
workspaceId: 'workspace-1',
|
|
specification: { values: { output: { aspect_ratio: '3:4' } } },
|
|
},
|
|
turns: [{ userMessage: '做一张新品主视觉' }],
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
command: {
|
|
kind: 'apply_input' as const,
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-edit-1',
|
|
input: {
|
|
kind: 'direct_edit' as const,
|
|
operations: [{ kind: 'set' as const, path: 'output.aspect_ratio', value: '16:9' }],
|
|
},
|
|
},
|
|
expected: {
|
|
client_command_id: 'operation-edit-1',
|
|
name: 'design.input.apply',
|
|
input: {
|
|
expected_direction_revision: 4,
|
|
client_operation_id: 'operation-edit-1',
|
|
input: {
|
|
kind: 'direct_edit',
|
|
operations: [{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
command: {
|
|
kind: 'request_quote' as const,
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
specificationRevision: 3,
|
|
clientOperationId: 'operation-quote-1',
|
|
},
|
|
expected: {
|
|
client_command_id: 'operation-quote-1',
|
|
name: 'design.quote.request',
|
|
input: {
|
|
expected_direction_revision: 4,
|
|
specification_revision: 3,
|
|
client_operation_id: 'operation-quote-1',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
command: {
|
|
kind: 'confirm_generation' as const,
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
quoteId: 'quote-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-confirm-1',
|
|
},
|
|
expected: {
|
|
client_command_id: 'operation-confirm-1',
|
|
name: 'design.generation.confirm',
|
|
input: {
|
|
quote_id: 'quote-1',
|
|
expected_direction_revision: 4,
|
|
client_operation_id: 'operation-confirm-1',
|
|
},
|
|
},
|
|
},
|
|
])('submits $expected.name with one stable operation identity', async ({ command, expected }) => {
|
|
const requests: Array<{ url: string; init?: RequestInit }> = [];
|
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
|
const url = String(input);
|
|
requests.push({ url, init });
|
|
if (url.endsWith('/commands')) {
|
|
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
|
}
|
|
if (url.endsWith('/runs/run-1')) {
|
|
return jsonResponse({ run_id: 'run-1', status: 'succeeded', error: null });
|
|
}
|
|
if (url.endsWith('/api/design/workspaces/workspace-1')) return jsonResponse(serverWorkspace);
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
const result = await module.submitCommand(command);
|
|
|
|
expect(result).toMatchObject({
|
|
clientOperationId: command.clientOperationId,
|
|
runId: 'run-1',
|
|
workspace: { workspace: { workspaceId: 'workspace-1' } },
|
|
});
|
|
const submitted = requests.find((request) => request.url.endsWith('/commands'));
|
|
expect(JSON.parse(String(submitted?.init?.body))).toEqual(expected);
|
|
});
|
|
|
|
it('surfaces a terminal Gateway failure without fetching a false-success Workspace', async () => {
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/commands')) {
|
|
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
|
}
|
|
if (url.endsWith('/runs/run-1')) {
|
|
return jsonResponse({
|
|
run_id: 'run-1',
|
|
status: 'failed',
|
|
error: { code: 'design_quote_blocked', message: 'blocked', retryable: false },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
await expect(module.submitCommand({
|
|
kind: 'request_quote',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
specificationRevision: 3,
|
|
clientOperationId: 'operation-quote-1',
|
|
})).rejects.toMatchObject({
|
|
status: 422,
|
|
code: 'design_quote_blocked',
|
|
} satisfies Partial<DesignWorkspaceModuleError>);
|
|
expect(fetchMock).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('/api/design/workspaces/workspace-1'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('projects an invalid reasoner result as a safe actionable message', async () => {
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/commands')) {
|
|
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
|
}
|
|
if (url.endsWith('/runs/run-1')) {
|
|
return jsonResponse({
|
|
run_id: 'run-1',
|
|
status: 'failed',
|
|
error: {
|
|
code: 'design_reasoner_invalid',
|
|
message: 'Design guidance returned an invalid result',
|
|
retryable: false,
|
|
},
|
|
});
|
|
}
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
await expect(module.submitCommand({
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-1',
|
|
input: { kind: 'chat', message: '画一只会做饭的机器人' },
|
|
})).rejects.toMatchObject({
|
|
status: 502,
|
|
code: 'design_reasoner_invalid',
|
|
message: 'AI 没有整理好这次想法,请再试一次',
|
|
commandOutcome: 'definitive_failure',
|
|
} satisfies Partial<DesignWorkspaceModuleError>);
|
|
});
|
|
|
|
it('marks polling auth failure as unknown after the command was accepted', async () => {
|
|
getTokenMock
|
|
.mockResolvedValueOnce('access-token')
|
|
.mockResolvedValueOnce('access-token')
|
|
.mockResolvedValueOnce(null);
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/commands')) {
|
|
return jsonResponse({ run_id: 'run-1', status: 'queued', error: null });
|
|
}
|
|
if (url.endsWith('/runs/run-1')) return jsonResponse({ detail: 'expired' }, 401);
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
await expect(module.submitCommand({
|
|
kind: 'apply_input',
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
expectedDirectionRevision: 4,
|
|
clientOperationId: 'operation-chat-1',
|
|
input: { kind: 'chat', message: '画一只会做饭的机器人' },
|
|
})).rejects.toMatchObject({
|
|
status: 401,
|
|
code: 'AUTH_EXPIRED',
|
|
commandOutcome: 'unknown',
|
|
} satisfies Partial<DesignWorkspaceModuleError>);
|
|
});
|
|
|
|
it('resumes the V2 event stream from the supplied sequence and normalizes deltas', async () => {
|
|
const socket = createSocket();
|
|
let streamUrl = '';
|
|
const fetchMock = vi.fn(async (input: string | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith('/stream-tickets')) {
|
|
return jsonResponse({ stream_url: 'https://works.example/api/agents/stream/ticket-1' });
|
|
}
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
webSocketFactory: async (url) => {
|
|
streamUrl = url;
|
|
setTimeout(() => socket.onopen?.(), 0);
|
|
return { socket };
|
|
},
|
|
});
|
|
|
|
const subscription = await module.openWorkspaceEvents({
|
|
workspaceId: 'workspace-1',
|
|
sessionId: 'session-1',
|
|
afterEventId: 'session-1:7',
|
|
});
|
|
const nextEvent = subscription.events[Symbol.asyncIterator]().next();
|
|
socket.onmessage?.({
|
|
data: JSON.stringify({
|
|
type: 'event',
|
|
event: {
|
|
session_id: 'session-1',
|
|
sequence: 8,
|
|
runtime: 'design',
|
|
type: 'design.assistant.delta',
|
|
schema_version: 1,
|
|
payload: {
|
|
workspace_id: 'workspace-1',
|
|
direction_id: 'direction-1',
|
|
client_operation_id: 'operation-chat-1',
|
|
direction_revision: 4,
|
|
chunk_index: 0,
|
|
delta: '正在整理设计方向',
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
|
|
await expect(nextEvent).resolves.toEqual({
|
|
done: false,
|
|
value: {
|
|
id: 'session-1:8',
|
|
type: 'design.assistant.delta',
|
|
workspaceId: 'workspace-1',
|
|
directionId: 'direction-1',
|
|
clientOperationId: 'operation-chat-1',
|
|
directionRevision: 4,
|
|
chunkIndex: 0,
|
|
delta: '正在整理设计方向',
|
|
},
|
|
});
|
|
expect(streamUrl).toContain('after_sequence=7');
|
|
expect(streamUrl).toMatch(/^wss:/);
|
|
subscription.close();
|
|
});
|
|
|
|
it('refreshes authentication once after a 401', async () => {
|
|
getTokenMock.mockResolvedValueOnce('expired-token').mockResolvedValueOnce('fresh-token');
|
|
const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => {
|
|
const authorization = new Headers(init?.headers).get('Authorization');
|
|
if (authorization === 'Bearer expired-token') return jsonResponse({ detail: 'expired' }, 401);
|
|
return jsonResponse(serverWorkspace);
|
|
});
|
|
const module = new WorksSquareDesignWorkspace({
|
|
apiBaseUrl: 'https://works.example',
|
|
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
});
|
|
|
|
await expect(module.getWorkspace('workspace-1')).resolves.toMatchObject({
|
|
workspace: { workspaceId: 'workspace-1' },
|
|
});
|
|
expect(getTokenMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ forceRefresh: true }));
|
|
});
|
|
});
|