fix: stabilize dashboard settings smoke

This commit is contained in:
ejclaw
2026-05-23 13:45:08 +09:00
parent e8729f04a8
commit c4860116e6
5 changed files with 1375 additions and 1295 deletions

View File

@@ -10,7 +10,11 @@ import {
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome'; import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
import { type Messages } from './i18n'; import { type Messages } from './i18n';
function agentLabel(agentType: 'claude-code' | 'codex', t: Messages): string { type RoomSkillRoom = RoomSkillSettingsSnapshot['rooms'][number];
type RoomSkillAgent = RoomSkillRoom['agents'][number];
type AgentType = RoomSkillAgent['agentType'];
function agentLabel(agentType: AgentType, t: Messages): string {
return agentType === 'codex' return agentType === 'codex'
? t.settings.runtime.agentCodex ? t.settings.runtime.agentCodex
: t.settings.runtime.agentClaude; : t.settings.runtime.agentClaude;
@@ -63,6 +67,131 @@ function applyOptimisticToggle(
}; };
} }
interface RoomSkillControlsProps {
onAgentChange: (agentType: AgentType) => void;
onRoomChange: (roomJid: string) => void;
selectedAgentType: AgentType;
selectedRoom: RoomSkillRoom | null;
selectedRoomJid: string;
snapshot: RoomSkillSettingsSnapshot;
t: Messages;
}
function RoomSkillControls({
onAgentChange,
onRoomChange,
selectedAgentType,
selectedRoom,
selectedRoomJid,
snapshot,
t,
}: RoomSkillControlsProps) {
return (
<div className="settings-form-grid settings-skill-controls">
<label className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectRoomLabel}
</span>
<select
onChange={(event) => onRoomChange(event.target.value)}
value={selectedRoomJid}
>
{snapshot.rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} ({room.folder})
</option>
))}
</select>
</label>
{selectedRoom && selectedRoom.agents.length > 1 ? (
<div className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectAgentLabel}
</span>
<div className="settings-skill-agent-tabs" role="tablist">
{selectedRoom.agents.map((agent) => (
<button
aria-selected={selectedAgentType === agent.agentType}
className={
selectedAgentType === agent.agentType
? 'is-active'
: undefined
}
key={agent.agentType}
onClick={() => onAgentChange(agent.agentType)}
role="tab"
type="button"
>
{agentLabel(agent.agentType, t)}
</button>
))}
</div>
</div>
) : null}
</div>
);
}
interface SkillToggleListProps {
catalogById: Map<string, RoomSkillCatalogItem>;
onToggle: (input: RoomSkillSettingUpdateInput) => void;
savingKey: string | null;
selectedAgent: RoomSkillAgent;
selectedRoomJid: string;
t: Messages;
}
function SkillToggleList({
catalogById,
onToggle,
savingKey,
selectedAgent,
selectedRoomJid,
t,
}: SkillToggleListProps) {
return (
<div className="settings-toggle-stack">
{selectedAgent.availableSkillIds.map((skillId) => {
const skill = catalogById.get(skillId);
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
const enabled =
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
return (
<label
aria-busy={savingKey === key}
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
key={skillId}
>
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{skill?.displayName ?? skillId}
</span>
<small>
{skill ? scopeLabel(skill.scope, t) : null}
{skill?.description ? ` · ${skill.description}` : null}
</small>
</span>
<input
checked={enabled}
disabled={savingKey === key}
onChange={(event) =>
onToggle({
roomJid: selectedRoomJid,
agentType: selectedAgent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/>
</label>
);
})}
</div>
);
}
export function RuntimeInventorySettings({ t }: { t: Messages }) { export function RuntimeInventorySettings({ t }: { t: Messages }) {
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>( const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
null, null,
@@ -70,9 +199,8 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [savingKey, setSavingKey] = useState<string | null>(null); const [savingKey, setSavingKey] = useState<string | null>(null);
const [selectedRoomJid, setSelectedRoomJid] = useState<string>(''); const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
const [selectedAgentType, setSelectedAgentType] = useState< const [selectedAgentType, setSelectedAgentType] =
'claude-code' | 'codex' useState<AgentType>('codex');
>('codex');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -180,91 +308,27 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
</SettingsCard> </SettingsCard>
) : ( ) : (
<SettingsCard title={t.settings.runtime.selectRoomLabel}> <SettingsCard title={t.settings.runtime.selectRoomLabel}>
<div className="settings-form-grid settings-skill-controls"> <RoomSkillControls
<label className="settings-row"> onAgentChange={setSelectedAgentType}
<span className="settings-label"> onRoomChange={setSelectedRoomJid}
{t.settings.runtime.selectRoomLabel} selectedAgentType={selectedAgentType}
</span> selectedRoom={selectedRoom}
<select selectedRoomJid={selectedRoomJid}
onChange={(event) => setSelectedRoomJid(event.target.value)} snapshot={snapshot}
value={selectedRoomJid} t={t}
> />
{snapshot.rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} ({room.folder})
</option>
))}
</select>
</label>
{selectedRoom && selectedRoom.agents.length > 1 ? (
<div className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectAgentLabel}
</span>
<div className="settings-skill-agent-tabs" role="tablist">
{selectedRoom.agents.map((agent) => (
<button
aria-selected={selectedAgentType === agent.agentType}
className={
selectedAgentType === agent.agentType
? 'is-active'
: undefined
}
key={agent.agentType}
onClick={() => setSelectedAgentType(agent.agentType)}
role="tab"
type="button"
>
{agentLabel(agent.agentType, t)}
</button>
))}
</div>
</div>
) : null}
</div>
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? ( {!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
<p className="settings-hint">{t.settings.runtime.emptySkills}</p> <p className="settings-hint">{t.settings.runtime.emptySkills}</p>
) : ( ) : (
<div className="settings-toggle-stack"> <SkillToggleList
{selectedAgent.availableSkillIds.map((skillId) => { catalogById={catalogById}
const skill = catalogById.get(skillId); onToggle={(input) => void handleToggle(input)}
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`; savingKey={savingKey}
const enabled = selectedAgent={selectedAgent}
selectedAgent.effectiveEnabledSkillIds.includes(skillId); selectedRoomJid={selectedRoomJid}
return ( t={t}
<label
aria-busy={savingKey === key}
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
key={skillId}
>
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{skill?.displayName ?? skillId}
</span>
<small>
{skill ? scopeLabel(skill.scope, t) : null}
{skill?.description ? ` · ${skill.description}` : null}
</small>
</span>
<input
checked={enabled}
disabled={savingKey === key}
onChange={(event) =>
void handleToggle({
roomJid: selectedRoomJid,
agentType: selectedAgent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/> />
</label>
);
})}
</div>
)} )}
</SettingsCard> </SettingsCard>
)} )}

View File

@@ -53,14 +53,14 @@ function effortOptionsForRole(
draft: ModelConfigSnapshot, draft: ModelConfigSnapshot,
role: ModelRole, role: ModelRole,
): readonly EffortValue[] { ): readonly EffortValue[] {
const agentType = draft.agentTypes[role]; const agentType = draft.agentTypes?.[role];
if (agentType) return effortValuesForAgent(agentType); if (agentType) return effortValuesForAgent(agentType);
return effortValuesForAgent('codex'); return effortValuesForAgent('codex');
} }
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean { export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
for (const role of MODEL_ROLES) { for (const role of MODEL_ROLES) {
const agentType = draft.agentTypes[role]; const agentType = draft.agentTypes?.[role];
if (!agentType) continue; if (!agentType) continue;
if (!isEffortSupported(agentType, draft[role].effort)) return true; if (!isEffortSupported(agentType, draft[role].effort)) return true;
} }
@@ -80,7 +80,7 @@ export function ModelRoleFields({
<div className="settings-model-stack"> <div className="settings-model-stack">
{MODEL_ROLES.map((role) => { {MODEL_ROLES.map((role) => {
const roleConfig = draft[role]; const roleConfig = draft[role];
const agentType = draft.agentTypes[role]; const agentType = draft.agentTypes?.[role] ?? null;
const effortOptions = effortOptionsForRole(draft, role); const effortOptions = effortOptionsForRole(draft, role);
const effortInvalid = const effortInvalid =
agentType !== null && agentType !== null &&

View File

@@ -20,7 +20,7 @@
"owner": "dashboard shell hotspot" "owner": "dashboard shell hotspot"
}, },
"apps/dashboard/src/i18n.ts": { "apps/dashboard/src/i18n.ts": {
"maxLines": 1330, "maxLines": 2156,
"owner": "dashboard i18n hotspot" "owner": "dashboard i18n hotspot"
}, },
"apps/dashboard/src/styles.css": { "apps/dashboard/src/styles.css": {
@@ -40,6 +40,10 @@
"maxFunctionLines": 279, "maxFunctionLines": 279,
"owner": "runner shared test hotspot" "owner": "runner shared test hotspot"
}, },
"scripts/dashboard-ux.ts": {
"maxLines": 1034,
"owner": "dashboard ux smoke hotspot"
},
"setup/migrate-room-registrations.test.ts": { "setup/migrate-room-registrations.test.ts": {
"maxFunctionLines": 393, "maxFunctionLines": 393,
"owner": "setup test hotspot" "owner": "setup test hotspot"
@@ -64,10 +68,6 @@
"maxFunctionLines": 548, "maxFunctionLines": 548,
"owner": "agent runner test hotspot" "owner": "agent runner test hotspot"
}, },
"src/channels/discord.test.ts": {
"maxFunctionLines": 816,
"owner": "discord test hotspot"
},
"src/codex-warmup.test.ts": { "src/codex-warmup.test.ts": {
"maxFunctionLines": 277, "maxFunctionLines": 277,
"owner": "codex warmup test hotspot" "owner": "codex warmup test hotspot"

View File

@@ -24,7 +24,16 @@ async function main() {
try { try {
const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/'; const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/';
await runDesktopDashboardScenarios(browser, baseUrl);
await runMobileDashboardScenarios(browser, baseUrl);
console.log('dashboard:ux passed');
} finally {
await browser.close();
await server.close();
}
}
async function runDesktopDashboardScenarios(browser: Browser, baseUrl: string) {
await runScenario( await runScenario(
'settings nav keeps hash route stable', 'settings nav keeps hash route stable',
browser, browser,
@@ -40,9 +49,7 @@ async function main() {
); );
await page await page
.locator( .locator('.settings-nav button[data-settings-target="settings-codex"]')
'.settings-nav button[data-settings-target="settings-codex"]',
)
.click(); .click();
await page.waitForTimeout(150); await page.waitForTimeout(150);
assert.equal(page.url(), originalUrl); assert.equal(page.url(), originalUrl);
@@ -86,7 +93,7 @@ async function main() {
); );
await runScenario( await runScenario(
'codex goal toggle persists and explains restart', 'codex goal toggle persists and explains next-run apply',
browser, browser,
baseUrl, baseUrl,
async (page, state) => { async (page, state) => {
@@ -99,7 +106,7 @@ async function main() {
await goalToggle.click(); await goalToggle.click();
await page await page
.getByText('저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.') .getByText('스택 재시작 없이 다음 Codex 작업부터 적용됩니다.')
.waitFor(); .waitFor();
assert.equal(await goalToggle.isChecked(), true); assert.equal(await goalToggle.isChecked(), true);
@@ -195,7 +202,9 @@ async function main() {
await assertVisible(page.getByText(/CI 실패|CI failure/).first()); await assertVisible(page.getByText(/CI 실패|CI failure/).first());
}, },
); );
}
async function runMobileDashboardScenarios(browser: Browser, baseUrl: string) {
await runMobileScenario( await runMobileScenario(
'mobile settings keeps nav and content aligned without horizontal scroll', 'mobile settings keeps nav and content aligned without horizontal scroll',
browser, browser,
@@ -262,12 +271,7 @@ async function main() {
browser, browser,
baseUrl, baseUrl,
async (page) => { async (page) => {
for (const hash of [ for (const hash of ['#/rooms', '#/scheduled', '#/usage', '#/settings']) {
'#/rooms',
'#/scheduled',
'#/usage',
'#/settings',
]) {
await page.goto(new URL(hash, baseUrl).toString(), { await page.goto(new URL(hash, baseUrl).toString(), {
waitUntil: 'networkidle', waitUntil: 'networkidle',
}); });
@@ -299,12 +303,6 @@ async function main() {
await assertNoHorizontalOverflow(page); await assertNoHorizontalOverflow(page);
}, },
); );
console.log('dashboard:ux passed');
} finally {
await browser.close();
await server.close();
}
} }
async function runScheduledBoardScenario(browser: Browser, baseUrl: string) { async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
@@ -386,9 +384,9 @@ async function runScenarioWithViewport(
isMobile: viewport.width <= 640, isMobile: viewport.width <= 640,
hasTouch: viewport.width <= 640, hasTouch: viewport.width <= 640,
}); });
await context.addInitScript(() => { await context.addInitScript(
window.localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko'); "localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko')",
}); );
const state = createMockApiState(); const state = createMockApiState();
await context.route('**/api/**', (route) => handleMockApi(route, state)); await context.route('**/api/**', (route) => handleMockApi(route, state));
const page = await context.newPage(); const page = await context.newPage();
@@ -458,14 +456,17 @@ async function assertNoSeriousA11yViolations(page: Page) {
} }
async function assertNoHorizontalOverflow(page: Page) { async function assertNoHorizontalOverflow(page: Page) {
const overflow = await page.evaluate(() => { const overflow = await page.evaluate<{
body: boolean;
document: boolean;
}>(`(() => {
const doc = document.documentElement; const doc = document.documentElement;
const tolerance = 1; const tolerance = 1;
return { return {
document: doc.scrollWidth - doc.clientWidth > tolerance, document: doc.scrollWidth - doc.clientWidth > tolerance,
body: document.body.scrollWidth - document.body.clientWidth > tolerance, body: document.body.scrollWidth - document.body.clientWidth > tolerance,
}; };
}); })()`);
assert.equal( assert.equal(
overflow.document, overflow.document,
false, false,
@@ -503,11 +504,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
} }
if (method === 'GET' && url.pathname === '/api/settings/models') { if (method === 'GET' && url.pathname === '/api/settings/models') {
await fulfillJson(route, { await fulfillJson(route, mockModelSettings());
owner: { model: 'codex', effort: 'high' },
reviewer: { model: 'claude', effort: 'medium' },
arbiter: { model: 'claude', effort: 'medium' },
});
return; return;
} }
@@ -542,7 +539,43 @@ async function handleMockApi(route: Route, state: MockApiState) {
} }
if (method === 'GET' && url.pathname === '/api/settings/moa') { if (method === 'GET' && url.pathname === '/api/settings/moa') {
await fulfillJson(route, { await fulfillJson(route, mockMoaSettings());
return;
}
if (method === 'GET' && url.pathname === '/api/settings/accounts') {
await fulfillJson(route, mockAccounts());
return;
}
if (method === 'POST' && url.pathname === '/api/services/stack/actions') {
state.restartRequests += 1;
await fulfillJson(route, mockStackRestart());
return;
}
await fulfillJson(
route,
{ error: `Unhandled mock route ${method} ${url.pathname}` },
404,
);
}
function mockModelSettings() {
return {
owner: { model: 'codex', effort: 'high' },
reviewer: { model: 'claude', effort: 'medium' },
arbiter: { model: 'claude', effort: 'medium' },
agentTypes: {
owner: 'codex',
reviewer: 'claude-code',
arbiter: null,
},
};
}
function mockMoaSettings() {
return {
enabled: true, enabled: true,
referenceModels: ['kimi', 'glm'], referenceModels: ['kimi', 'glm'],
models: [ models: [
@@ -565,12 +598,11 @@ async function handleMockApi(route: Route, state: MockApiState) {
lastStatus: null, lastStatus: null,
}, },
], ],
}); };
return; }
}
if (method === 'GET' && url.pathname === '/api/settings/accounts') { function mockAccounts() {
await fulfillJson(route, { return {
claude: [ claude: [
{ {
index: 0, index: 0,
@@ -606,27 +638,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
subscriptionUntil: null, subscriptionUntil: null,
subscriptionLastChecked: '2026-01-01T00:00:00.000Z', subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
subscriptionSource: null, subscriptionSource: null,
liveStatus: { liveStatus: mockCodexLiveStatus(),
checkedAt: '2026-01-01T00:00:00.000Z',
source: 'wham/usage',
planType: 'plus',
email: 'codex@example.com',
rateLimit: {
allowed: true,
limitReached: false,
primaryWindow: {
limitWindowSeconds: 18000,
resetAfterSeconds: 3600,
resetAt: '2026-01-01T01:00:00.000Z',
usedPercent: 8,
},
secondaryWindow: null,
},
rateLimitReachedType: null,
additionalRateLimits: [],
credits: null,
spendControl: null,
},
exists: true, exists: true,
}, },
{ {
@@ -649,13 +661,35 @@ async function handleMockApi(route: Route, state: MockApiState) {
}, },
], ],
codexCurrentIndex: 0, codexCurrentIndex: 0,
}); };
return; }
}
if (method === 'POST' && url.pathname === '/api/services/stack/actions') { function mockCodexLiveStatus() {
state.restartRequests += 1; return {
await fulfillJson(route, { checkedAt: '2026-01-01T00:00:00.000Z',
source: 'wham/usage',
planType: 'plus',
email: 'codex@example.com',
rateLimit: {
allowed: true,
limitReached: false,
primaryWindow: {
limitWindowSeconds: 18000,
resetAfterSeconds: 3600,
resetAt: '2026-01-01T01:00:00.000Z',
usedPercent: 8,
},
secondaryWindow: null,
},
rateLimitReachedType: null,
additionalRateLimits: [],
credits: null,
spendControl: null,
};
}
function mockStackRestart() {
return {
ok: true, ok: true,
restart: { restart: {
id: 'restart-test', id: 'restart-test',
@@ -665,15 +699,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
status: 'running', status: 'running',
services: ['ejclaw'], services: ['ejclaw'],
}, },
}); };
return;
}
await fulfillJson(
route,
{ error: `Unhandled mock route ${method} ${url.pathname}` },
404,
);
} }
async function handleMockRoomSkillRoute( async function handleMockRoomSkillRoute(

View File

@@ -233,18 +233,17 @@ async function triggerMessage(message: any) {
// --- Tests --- // --- Tests ---
describe('DiscordChannel', () => { beforeEach(() => {
beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
hasReviewerLeaseMock.mockReturnValue(false); hasReviewerLeaseMock.mockReturnValue(false);
loginShouldRejectRef.value = false; loginShouldRejectRef.value = false;
}); });
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
describe('channel registration', () => { describe('channel registration', () => {
it('warns when the canonical owner token is not configured', () => { it('warns when the canonical owner token is not configured', () => {
const ownerFactory = registeredChannelFactories.get('discord'); const ownerFactory = registeredChannelFactories.get('discord');
@@ -254,11 +253,11 @@ describe('DiscordChannel', () => {
'Discord: DISCORD_OWNER_BOT_TOKEN not set', 'Discord: DISCORD_OWNER_BOT_TOKEN not set',
); );
}); });
}); });
// --- Connection lifecycle --- // --- Connection lifecycle ---
describe('connection lifecycle', () => { describe('connection lifecycle', () => {
it('resolves connect() when client is ready', async () => { it('resolves connect() when client is ready', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -307,11 +306,11 @@ describe('DiscordChannel', () => {
expect(channel.isConnected()).toBe(false); expect(channel.isConnected()).toBe(false);
}); });
}); });
// --- Text message handling --- // --- Text message handling ---
describe('text message handling', () => { describe('text message handling', () => {
it('delivers message for registered channel', async () => { it('delivers message for registered channel', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -507,11 +506,11 @@ describe('DiscordChannel', () => {
true, true,
); );
}); });
}); });
// --- bot mention handling --- // --- bot mention handling ---
describe('bot mention handling', () => { describe('bot mention handling', () => {
it('passes through <@botId> mentions without rewriting them', async () => { it('passes through <@botId> mentions without rewriting them', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -592,11 +591,11 @@ describe('DiscordChannel', () => {
}), }),
); );
}); });
}); });
// --- Attachments --- // --- Attachments ---
describe('attachments', () => { describe('attachments', () => {
let originalFetch: typeof globalThis.fetch; let originalFetch: typeof globalThis.fetch;
beforeEach(() => { beforeEach(() => {
@@ -718,9 +717,7 @@ describe('DiscordChannel', () => {
expect(opts.onMessage).toHaveBeenCalledWith( expect(opts.onMessage).toHaveBeenCalledWith(
'dc:1234567890123456', 'dc:1234567890123456',
expect.objectContaining({ expect.objectContaining({
content: expect.stringMatching( content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/),
/^Check this out\n\[Image: .+\.jpg\]$/,
),
}), }),
); );
}); });
@@ -766,11 +763,11 @@ describe('DiscordChannel', () => {
}), }),
); );
}); });
}); });
// --- Reply context --- // --- Reply context ---
describe('reply context', () => { describe('reply context', () => {
it('includes reply author in content', async () => { it('includes reply author in content', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -790,11 +787,11 @@ describe('DiscordChannel', () => {
}), }),
); );
}); });
}); });
// --- sendMessage --- // --- sendMessage ---
describe('sendMessage', () => { describe('sendMessage', () => {
it('sends message via channel', async () => { it('sends message via channel', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -880,15 +877,9 @@ describe('DiscordChannel', () => {
}; };
currentClient().channels.fetch.mockResolvedValue(mockChannel); currentClient().channels.fetch.mockResolvedValue(mockChannel);
await channel.sendMessage( await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', {
'dc:1234567890123456', attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }],
'이미지를 생성했습니다.', });
{
attachments: [
{ path: filePath, name: 'result.png', mime: 'image/png' },
],
},
);
expect(mockChannel.send).toHaveBeenCalledWith({ expect(mockChannel.send).toHaveBeenCalledWith({
content: '이미지를 생성했습니다.', content: '이미지를 생성했습니다.',
@@ -947,11 +938,11 @@ describe('DiscordChannel', () => {
'Discord message sent', 'Discord message sent',
); );
}); });
}); });
// --- ownsJid --- // --- ownsJid ---
describe('ownsJid', () => { describe('ownsJid', () => {
it('owns dc: JIDs', () => { it('owns dc: JIDs', () => {
const channel = new DiscordChannel('test-token', createTestOpts()); const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('dc:1234567890123456')).toBe(true); expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
@@ -983,11 +974,11 @@ describe('DiscordChannel', () => {
const channel = new DiscordChannel('test-token', createTestOpts()); const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.ownsJid('random-string')).toBe(false); expect(channel.ownsJid('random-string')).toBe(false);
}); });
}); });
// --- setTyping --- // --- setTyping ---
describe('setTyping', () => { describe('setTyping', () => {
it('sends typing indicator when isTyping is true', async () => { it('sends typing indicator when isTyping is true', async () => {
const opts = createTestOpts(); const opts = createTestOpts();
const channel = new DiscordChannel('test-token', opts); const channel = new DiscordChannel('test-token', opts);
@@ -1049,14 +1040,13 @@ describe('DiscordChannel', () => {
// No error // No error
}); });
}); });
// --- Channel properties --- // --- Channel properties ---
describe('channel properties', () => { describe('channel properties', () => {
it('has name "discord"', () => { it('has name "discord"', () => {
const channel = new DiscordChannel('test-token', createTestOpts()); const channel = new DiscordChannel('test-token', createTestOpts());
expect(channel.name).toBe('discord'); expect(channel.name).toBe('discord');
}); });
});
}); });