mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* chore: unify product name display to OpenDesign repo-wide Rename the user-facing product name from "Open Design" to "OpenDesign" across documentation, web/desktop UI strings, i18n locales, prompts, plugin/skill/design-system content, changelogs, and specs, matching the landing-page rename already in flight. Deliberately excluded so installed-app identity stays untouched: - packages/release PRODUCT_NAME and everything derived from it (app bundle names, installer/registry identity, updater feeds) - tools/pack, tools/release, apps/packaged, landing page (PR #6983) - mocks/ recorded traces and LICENSE - test fixtures and comments that encode real on-disk install paths (Open Design.app, Program Files\Open Design, AppData paths) Validated with pnpm guard, pnpm typecheck, and targeted web/desktop/host test runs over the renamed strings. * fix(web): keep two-letter author monogram for CamelCase brand names authorInitials split words on whitespace only, so the renamed single-token author name "OpenDesign" produced a one-letter monogram. Treat lower-to-upper CamelCase boundaries as word breaks so the avatar fallback stays "OD". * fix(release): restore spaced Windows source artifact names in win.ps1 tools/pack still derives builder outputs from the unchanged PRODUCT_NAME ("Open Design"), so the asset script must look for the spaced filenames. Matches the mac/mac-intel/linux asset scripts, which were already kept on the original names. --------- Co-authored-by: Joey <236967869+joeylee12629-star@users.noreply.github.com>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
/**
|
|
* Gate coverage for the "next step" affordance under the last assistant
|
|
* message. The surface is reserved for successful, artifact-backed delivery;
|
|
* pure answers, interruptions, and incomplete work stay compact.
|
|
*/
|
|
|
|
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
|
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
|
import {
|
|
PROJECT_GENERATE_ARTIFACT_PROMPT,
|
|
} from '../../src/components/NextStepActions';
|
|
import { en } from '../../src/i18n/locales/en';
|
|
import type { ChatMessage, ProjectFile } from '../../src/types';
|
|
|
|
beforeAll(() => {
|
|
const store = new Map<string, string>();
|
|
Object.defineProperty(window, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
clear: () => store.clear(),
|
|
getItem: (key: string) => store.get(key) ?? null,
|
|
removeItem: (key: string) => store.delete(key),
|
|
setItem: (key: string, value: string) => store.set(key, value),
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
function baseMessage(overrides: Partial<ChatMessage> = {}): ChatMessage {
|
|
return {
|
|
id: 'msg-1',
|
|
role: 'assistant',
|
|
content: 'Done.',
|
|
runStatus: 'succeeded',
|
|
startedAt: 1700000000,
|
|
endedAt: 1700000005,
|
|
events: [{ kind: 'text', text: 'Done.' } as NonNullable<ChatMessage['events']>[number]],
|
|
producedFiles: [],
|
|
...overrides,
|
|
} as ChatMessage;
|
|
}
|
|
|
|
function producedFile(name: string, kind: ProjectFile['kind'] = 'html'): ProjectFile {
|
|
return {
|
|
name,
|
|
path: name,
|
|
size: 100,
|
|
mtime: 1700000005,
|
|
kind,
|
|
mime: kind === 'html' ? 'text/html' : 'application/octet-stream',
|
|
} as ProjectFile;
|
|
}
|
|
|
|
const handlers = () => ({
|
|
onArtifactShare: vi.fn(),
|
|
onToolboxAction: vi.fn(),
|
|
onNextStepPromptAction: vi.fn(),
|
|
});
|
|
|
|
describe('AssistantMessage next-step affordance', () => {
|
|
it('routes Share through the More → Share cascade with the file name', () => {
|
|
const h = handlers();
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [producedFile('landing.html')] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...h}
|
|
/>,
|
|
);
|
|
expect(screen.getByRole('group', { name: en['nextStep.title'] })).toBeTruthy();
|
|
expect(screen.queryByText(en['nextStep.title'])).toBeNull();
|
|
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
|
|
fireEvent.mouseEnter(screen.getByTestId('next-step-more-share'));
|
|
fireEvent.click(screen.getByTestId('next-step-share-share'));
|
|
expect(h.onArtifactShare).toHaveBeenCalledWith('landing.html');
|
|
});
|
|
|
|
it('does not render when the message is not the last assistant message', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [producedFile('landing.html')] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast={false}
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('reaches Contribute (share to OpenDesign) through the More → Share cascade', () => {
|
|
const onShareToOpenDesign = vi.fn();
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [producedFile('landing.html')] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
onFeedback={vi.fn()}
|
|
onShareToOpenDesign={onShareToOpenDesign}
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
fireEvent.mouseEnter(screen.getByTestId('next-step-toolbox-more'));
|
|
fireEvent.mouseEnter(screen.getByTestId('next-step-more-share'));
|
|
fireEvent.click(screen.getByTestId('next-step-share-contribute'));
|
|
expect(onShareToOpenDesign).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not render after a simple answer with no deliverable', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('does not render for a simple answer without a project id', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [] })}
|
|
streaming={false}
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('renders project recovery actions when the turn produced no previewable artifact', () => {
|
|
const h = handlers();
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [producedFile('notes.md', 'text')] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...h}
|
|
/>,
|
|
);
|
|
// #5517 shape: a turn with produced files but no tool ops renders the flat
|
|
// produced-files block, not the collapsible tool-op summary.
|
|
expect(document.querySelector('.produced-files')?.textContent).toContain('notes.md');
|
|
expect(screen.queryByTestId('file-ops-summary')).toBeNull();
|
|
expect(screen.getByTestId('next-step-actions')).toBeTruthy();
|
|
expect(screen.getByText(en['nextStep.projectGenerateArtifactTitle'])).toBeTruthy();
|
|
fireEvent.click(screen.getByTestId('next-step-project-action-project-generate-artifact'));
|
|
expect(h.onNextStepPromptAction).toHaveBeenCalledWith(PROJECT_GENERATE_ARTIFACT_PROMPT);
|
|
});
|
|
|
|
it('does not reuse an earlier artifact for a pure-answer turn', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
projectFiles={[producedFile('landing.html')]}
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('does not render incomplete brand extraction next steps after cancellation', () => {
|
|
const h = handlers();
|
|
const onContinueExtraction = vi.fn();
|
|
const onContinueAiExtraction = vi.fn();
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({
|
|
runStatus: 'canceled',
|
|
content: 'Stopped.',
|
|
producedFiles: [],
|
|
})}
|
|
streaming={false}
|
|
projectId="proj-brand"
|
|
isLast
|
|
nextStepVariant="brand-extraction"
|
|
onNextStepContinueExtraction={onContinueExtraction}
|
|
onNextStepContinueAiExtraction={onContinueAiExtraction}
|
|
{...h}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
expect(onContinueExtraction).not.toHaveBeenCalled();
|
|
expect(onContinueAiExtraction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refreshes the brand continuation busy state on memoized rows', () => {
|
|
const h = handlers();
|
|
const onContinueExtraction = vi.fn();
|
|
const message = baseMessage({
|
|
runStatus: 'succeeded',
|
|
content: 'Done.',
|
|
producedFiles: [producedFile('brand.html')],
|
|
});
|
|
const view = render(
|
|
<AssistantMessage
|
|
message={message}
|
|
streaming={false}
|
|
projectId="proj-brand"
|
|
isLast
|
|
nextStepVariant="brand-programmatic-incomplete"
|
|
onNextStepContinueExtraction={onContinueExtraction}
|
|
nextStepContinueExtractionBusy={false}
|
|
{...h}
|
|
/>,
|
|
);
|
|
|
|
const firstButton = screen.getByTestId('next-step-brand-action-brand-continue-extraction');
|
|
fireEvent.click(firstButton);
|
|
expect(onContinueExtraction).toHaveBeenCalledTimes(1);
|
|
|
|
view.rerender(
|
|
<AssistantMessage
|
|
message={message}
|
|
streaming={false}
|
|
projectId="proj-brand"
|
|
isLast
|
|
nextStepVariant="brand-programmatic-incomplete"
|
|
onNextStepContinueExtraction={onContinueExtraction}
|
|
nextStepContinueExtractionBusy
|
|
{...h}
|
|
/>,
|
|
);
|
|
|
|
const busyButton = screen.getByTestId('next-step-brand-action-brand-continue-extraction');
|
|
expect((busyButton as HTMLButtonElement).disabled).toBe(true);
|
|
fireEvent.click(busyButton);
|
|
expect(onContinueExtraction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not render when the handlers are not wired', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [producedFile('landing.html')] })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('does not render after a failed turn', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [], runStatus: 'failed' })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('does not render after a canceled turn', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={baseMessage({ producedFiles: [], runStatus: 'canceled' })}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// A clarification turn ends the run while its inline <question-form> is still
|
|
// waiting for the user; the next-step card must hold back until
|
|
// the answers (or a skip-all, which submits through the same path) arrive as
|
|
// the following user message.
|
|
describe('AssistantMessage next-step affordance during the question phase', () => {
|
|
const QUESTION_FORM_CONTENT = [
|
|
'Got it — a couple of quick questions first.',
|
|
'',
|
|
'<question-form id="discovery" title="Brief">',
|
|
'{"questions":[{"id":"studio","label":"Studio name","type":"text","required":true}]}',
|
|
'</question-form>',
|
|
].join('\n');
|
|
|
|
function questionFormMessage(content = QUESTION_FORM_CONTENT): ChatMessage {
|
|
return baseMessage({
|
|
content,
|
|
events: [{ kind: 'text', text: content } as NonNullable<ChatMessage['events']>[number]],
|
|
producedFiles: [producedFile('brief.html')],
|
|
});
|
|
}
|
|
|
|
it('does not render while the question form is still unanswered', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={questionFormMessage()}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.getByText('Brief')).toBeTruthy();
|
|
expect(screen.getByText('Studio name')).toBeTruthy();
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('does not render while an unterminated question form is pending', () => {
|
|
const content = 'Quick brief first.\n\n<question-form id="discovery" title="Brief">\n{"questions":[';
|
|
render(
|
|
<AssistantMessage
|
|
message={questionFormMessage(content)}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('next-step-actions')).toBeNull();
|
|
});
|
|
|
|
it('renders once the next user message submits the form answers', () => {
|
|
render(
|
|
<AssistantMessage
|
|
message={questionFormMessage()}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
nextUserContent={'[form answers — discovery]\n- Studio name: Cobalt Studio'}
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.getByTestId('next-step-actions')).toBeTruthy();
|
|
});
|
|
|
|
it('ignores a suppressed direction form (locked design system) when gating', () => {
|
|
const content = [
|
|
'Pick a direction.',
|
|
'',
|
|
'<question-form id="direction" title="Visual direction">',
|
|
'{"questions":[{"id":"dir","label":"Direction","type":"direction-cards","options":["A","B"]}]}',
|
|
'</question-form>',
|
|
].join('\n');
|
|
render(
|
|
<AssistantMessage
|
|
message={questionFormMessage(content)}
|
|
streaming={false}
|
|
projectId="proj-1"
|
|
isLast
|
|
suppressDirectionForms
|
|
{...handlers()}
|
|
/>,
|
|
);
|
|
expect(screen.getByTestId('next-step-actions')).toBeTruthy();
|
|
});
|
|
});
|