// @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>()), useTranslation: () => ({ t: (key: string, opts?: Record) => (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(async () => {}); vi.mock('../../api/tasks', async (importOriginal) => ({ ...(await importOriginal>()), 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(); expect(screen.getByText('デプロイ先はどちらにしますか?')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /本番/ })).toBeInTheDocument(); expect(screen.getByText('検証後に昇格')).toBeInTheDocument(); }); it('選択して送信すると、回答テキストがコメントとして投稿される', async () => { render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull(); rerender(); 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(); 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: '確認です ', 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(); expect(container.querySelector('img')).toBeNull(); expect(container.querySelector('script')).toBeNull(); // イベントハンドラ属性が生きた要素に付いていないこと(文字列として本文に // 見えているのは正しい姿なので、innerHTML の文字列検索では見ない)。 expect(container.querySelector('[onerror]')).toBeNull(); // 読めるソースとして残っていること(黙って消えるのではなく見える)。 expect(container.textContent).toContain(' { 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(); expect(screen.getByText(/どうしますか/)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'askCard.submit' })).toBeNull(); }); });