257 lines
12 KiB
TypeScript
257 lines
12 KiB
TypeScript
// @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();
|
||
});
|
||
});
|