Files
maestro/ui/src/components/chat/AskUserCard.test.tsx
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

257 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
/**
* AskUser の質問カード (issue #985)。
*
* kind='ask' コメントの本文が {type:'ask_questions'} JSON なら、選択肢を
* タップで答えられるカードを描画する。回答は既存のコメント送信 API に載せる
* (waiting_human のジョブはコメント投稿で再開する既存経路をそのまま使う)。
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
vi.mock('react-i18next', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({ t: (key: string, opts?: Record<string, unknown>) => (opts?.defaultValue as string) ?? key }),
}));
vi.mock('mermaid', () => ({ default: { initialize: vi.fn(), run: vi.fn().mockResolvedValue(undefined) } }));
vi.mock('../embed/EmbedBlock', () => ({ EmbedBlock: () => null }));
// 実 API (postLocalTaskComment) と同じ引数で受ける。何でも受け取る形にすると
// シグネチャが変わってもテストが気づかない。
//
// vi.mock のファクトリは実モジュールの型と突き合わされないので、差し替え関数に
// satisfies を付けて「実シグネチャと同じ形」をコンパイラに確かめさせる。
// これが無いと、引数が増えたときに黙って落とすだけのモックになる。
type PostComment = typeof import('../../api/tasks').postLocalTaskComment;
const postComment = vi.fn<PostComment>(async () => {});
vi.mock('../../api/tasks', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
postLocalTaskComment: ((...args) => postComment(...args)) satisfies PostComment,
}));
import { AskUserCard, tryParseAskQuestions, findInteractiveAskId } from './AskUserCard';
const PAYLOAD = JSON.stringify({
type: 'ask_questions',
questions: [
{
question: 'デプロイ先はどちらにしますか?',
header: 'デプロイ先',
options: [
{ label: '本番', description: '即時反映' },
{ label: 'ステージング', description: '検証後に昇格' },
],
},
],
});
beforeEach(() => postComment.mockClear());
describe('tryParseAskQuestions', () => {
it('ask_questions JSON を読む', () => {
expect(tryParseAskQuestions(PAYLOAD)?.questions).toHaveLength(1);
});
it('自由文・別種の JSON は null', () => {
expect(tryParseAskQuestions('❓ 確認事項\n\nどうしますか?')).toBeNull();
expect(tryParseAskQuestions(JSON.stringify({ type: 'thinking', text: 'x' }))).toBeNull();
});
it('questions が空なら null(壊れたペイロードをカード化しない)', () => {
expect(tryParseAskQuestions(JSON.stringify({ type: 'ask_questions', questions: [] }))).toBeNull();
});
});
describe('AskUserCard', () => {
const parsed = () => tryParseAskQuestions(PAYLOAD)!;
it('質問・選択肢・補足を描画する', () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
expect(screen.getByText('デプロイ先はどちらにしますか?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /本番/ })).toBeInTheDocument();
expect(screen.getByText('検証後に昇格')).toBeInTheDocument();
});
it('選択して送信すると、回答テキストがコメントとして投稿される', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /本番/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalledTimes(1));
const [taskId, body] = postComment.mock.calls[0];
expect(taskId).toBe(7);
expect(body).toContain('デプロイ先');
expect(body).toContain('本番');
});
it('自由記述(その他)で答えられる', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /askCard.other/ }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'カナリアで 10% だけ' } });
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
expect(postComment.mock.calls[0][1]).toContain('カナリアで 10% だけ');
});
it('未回答の質問があるうちは送信できない', () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
expect(screen.getByRole('button', { name: 'askCard.submit' })).toBeDisabled();
});
it('multiSelect は複数選べて、両方が回答に載る', async () => {
const multi = tryParseAskQuestions(JSON.stringify({
type: 'ask_questions',
questions: [{
question: '対象は?',
multiSelect: true,
options: [{ label: 'API' }, { label: 'UI' }, { label: 'docs' }],
}],
}))!;
render(<AskUserCard taskId={7} payload={multi} />);
fireEvent.click(screen.getByRole('button', { name: /API/ }));
fireEvent.click(screen.getByRole('button', { name: /UI/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
const body = postComment.mock.calls[0][1];
expect(body).toContain('API');
expect(body).toContain('UI');
});
it('送信後はボタンが無効になる(二重送信の防止)', async () => {
render(<AskUserCard taskId={7} payload={parsed()} />);
fireEvent.click(screen.getByRole('button', { name: /本番/ }));
fireEvent.click(screen.getByRole('button', { name: 'askCard.submit' }));
await waitFor(() => expect(postComment).toHaveBeenCalled());
expect(screen.getByRole('button', { name: 'askCard.submitted' })).toBeDisabled();
});
});
/**
* stale カード対策: 回答済み・タスク非停車中の履歴カードは読み取り専用にする。
* phase はローカル state なのでリロードで 'idle' に戻り、放置すると
* 完了済みタスクの古いカードをワンタップで誤爆再実行できてしまう。
*/
describe('AskUserCard readOnly', () => {
const parsed = () => tryParseAskQuestions(PAYLOAD)!;
it('選択肢は見えるが押せず、送信ボタンは出さない', () => {
render(<AskUserCard taskId={7} payload={parsed()} readOnly />);
expect(screen.getByRole('button', { name: /本番/ })).toBeDisabled();
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
expect(screen.getByText('askCard.closed')).toBeInTheDocument();
});
});
describe('findInteractiveAskId', () => {
const c = (id: number, author: string, kind: string) =>
({ id, taskId: 7, author, kind, body: 'x', createdAt: '', attachments: [] }) as never;
it('waiting_human で未回答の最新 ask だけが対話可能', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask')];
expect(findInteractiveAskId(comments, 'waiting_human')).toBe(2);
});
it('ask より後にユーザーコメントがあれば回答済みとして null', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask'), c(3, 'user', 'comment')];
expect(findInteractiveAskId(comments, 'waiting_human')).toBeNull();
});
it('タスクが waiting_human でなければ null(完了・実行中は受付終了)', () => {
const comments = [c(1, 'user', 'request'), c(2, 'agent', 'ask')];
expect(findInteractiveAskId(comments, 'succeeded')).toBeNull();
expect(findInteractiveAskId(comments, 'running')).toBeNull();
expect(findInteractiveAskId(comments, undefined)).toBeNull();
});
});
/**
* 配線テスト: ChatMessage が kind='ask' の構造化コメントからカードを実際に
* 出すこと。カード単体テストだけだと、ChatMessage 側の分岐を消しても
* 誰も気づかない(推論表示の実装で実際に踏んだ穴)。
*/
describe('ChatMessage への配線', () => {
it('構造化 ask コメントでカードが描画される', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 1, taskId: 7, author: 'agent', kind: 'ask', body: PAYLOAD,
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.getByRole('button', { name: /本番/ })).toBeInTheDocument();
});
it('既定は読み取り専用(明示的に askInteractive を渡した場所だけ回答できる)', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 3, taskId: 7, author: 'agent', kind: 'ask', body: PAYLOAD,
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
const { rerender } = render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
rerender(<ChatMessage comment={comment} taskId={7} askInteractive />);
expect(screen.getByRole('button', { name: 'askCard.submit' })).toBeInTheDocument();
});
/**
* issue #1197: 質問文が Markdown のまま素で出ていた。カード単体ではなく
* ChatMessage 経由で見る — 「カードは整形するが ChatMessage が別物を出す」
* 穴を塞ぐため。
*/
it('質問文の Markdown が整形されて出る(強調・箇条書き)', async () => {
const { ChatMessage } = await import('./ChatMessage');
const body = JSON.stringify({
type: 'ask_questions',
questions: [{
question: '**どちら**を優先しますか?\n\n- 速さ\n- 正確さ',
options: [{ label: '速さ' }, { label: '正確さ' }],
}],
});
const comment = {
id: 11, taskId: 7, author: 'agent', kind: 'ask', body,
createdAt: '2026-08-19T00:00:00Z', attachments: [],
} as never;
const { container } = render(<ChatMessage comment={comment} taskId={7} />);
expect(container.querySelector('strong')?.textContent).toBe('どちら');
expect(container.querySelectorAll('li')).toHaveLength(2);
// 生の記号が残っていたら整形されていない。
expect(container.textContent).not.toContain('**');
expect(container.textContent).not.toContain('- 速さ');
});
/**
* 質問文はモデルが書いた文字列。整形に相乗りしたぶん、生 HTML の扱いも
* 他の吹き出し(MarkdownText の renderer.html + DOMPurify)と同じでなければ
* ならない。ここだけ緩めない。
*/
it('質問文に混ざった生 HTML は生きた DOM にしない', async () => {
const { ChatMessage } = await import('./ChatMessage');
const body = JSON.stringify({
type: 'ask_questions',
questions: [{
question: '確認です <img src=x onerror="alert(1)"> <script>alert(2)</script>',
options: [{ label: 'はい' }],
}],
});
const comment = {
id: 12, taskId: 7, author: 'agent', kind: 'ask', body,
createdAt: '2026-08-19T00:00:00Z', attachments: [],
} as never;
const { container } = render(<ChatMessage comment={comment} taskId={7} />);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('script')).toBeNull();
// イベントハンドラ属性が生きた要素に付いていないこと(文字列として本文に
// 見えているのは正しい姿なので、innerHTML の文字列検索では見ない)。
expect(container.querySelector('[onerror]')).toBeNull();
// 読めるソースとして残っていること(黙って消えるのではなく見える)。
expect(container.textContent).toContain('<img');
});
it('自由文 ask コメントは従来の吹き出しのまま', async () => {
const { ChatMessage } = await import('./ChatMessage');
const comment = {
id: 2, taskId: 7, author: 'agent', kind: 'ask', body: '❓ 確認事項\n\nどうしますか?',
createdAt: '2026-07-30T00:00:00Z', attachments: [],
} as never;
render(<ChatMessage comment={comment} taskId={7} />);
expect(screen.getByText(/どうしますか/)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull();
});
});