Files
open-design/apps/web/tests/components/AssistantMessage.nextStep.test.tsx
T
f1a0b60c6c chore: unify product name display to OpenDesign repo-wide (#6998)
* 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>
2026-08-18 07:09:45 +00:00

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();
});
});