diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx index dcf35dd..c638626 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.tsx +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -10,7 +10,11 @@ import { import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome'; 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' ? t.settings.runtime.agentCodex : 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 ( +
+ + + {selectedRoom && selectedRoom.agents.length > 1 ? ( +
+ + {t.settings.runtime.selectAgentLabel} + +
+ {selectedRoom.agents.map((agent) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +interface SkillToggleListProps { + catalogById: Map; + onToggle: (input: RoomSkillSettingUpdateInput) => void; + savingKey: string | null; + selectedAgent: RoomSkillAgent; + selectedRoomJid: string; + t: Messages; +} + +function SkillToggleList({ + catalogById, + onToggle, + savingKey, + selectedAgent, + selectedRoomJid, + t, +}: SkillToggleListProps) { + return ( +
+ {selectedAgent.availableSkillIds.map((skillId) => { + const skill = catalogById.get(skillId); + const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`; + const enabled = + selectedAgent.effectiveEnabledSkillIds.includes(skillId); + return ( + + ); + })} +
+ ); +} + export function RuntimeInventorySettings({ t }: { t: Messages }) { const [snapshot, setSnapshot] = useState( null, @@ -70,9 +199,8 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) { const [error, setError] = useState(null); const [savingKey, setSavingKey] = useState(null); const [selectedRoomJid, setSelectedRoomJid] = useState(''); - const [selectedAgentType, setSelectedAgentType] = useState< - 'claude-code' | 'codex' - >('codex'); + const [selectedAgentType, setSelectedAgentType] = + useState('codex'); useEffect(() => { let cancelled = false; @@ -180,91 +308,27 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) { ) : ( -
- - - {selectedRoom && selectedRoom.agents.length > 1 ? ( -
- - {t.settings.runtime.selectAgentLabel} - -
- {selectedRoom.agents.map((agent) => ( - - ))} -
-
- ) : null} -
+ {!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (

{t.settings.runtime.emptySkills}

) : ( -
- {selectedAgent.availableSkillIds.map((skillId) => { - const skill = catalogById.get(skillId); - const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`; - const enabled = - selectedAgent.effectiveEnabledSkillIds.includes(skillId); - return ( - - ); - })} -
+ void handleToggle(input)} + savingKey={savingKey} + selectedAgent={selectedAgent} + selectedRoomJid={selectedRoomJid} + t={t} + /> )}
)} diff --git a/apps/dashboard/src/SettingsModelFields.tsx b/apps/dashboard/src/SettingsModelFields.tsx index 3e44e80..0db9531 100644 --- a/apps/dashboard/src/SettingsModelFields.tsx +++ b/apps/dashboard/src/SettingsModelFields.tsx @@ -53,14 +53,14 @@ function effortOptionsForRole( draft: ModelConfigSnapshot, role: ModelRole, ): readonly EffortValue[] { - const agentType = draft.agentTypes[role]; + const agentType = draft.agentTypes?.[role]; if (agentType) return effortValuesForAgent(agentType); return effortValuesForAgent('codex'); } export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean { for (const role of MODEL_ROLES) { - const agentType = draft.agentTypes[role]; + const agentType = draft.agentTypes?.[role]; if (!agentType) continue; if (!isEffortSupported(agentType, draft[role].effort)) return true; } @@ -80,7 +80,7 @@ export function ModelRoleFields({
{MODEL_ROLES.map((role) => { const roleConfig = draft[role]; - const agentType = draft.agentTypes[role]; + const agentType = draft.agentTypes?.[role] ?? null; const effortOptions = effortOptionsForRole(draft, role); const effortInvalid = agentType !== null && diff --git a/quality/code-quality-budgets.json b/quality/code-quality-budgets.json index 9b7f93d..b40aeaf 100644 --- a/quality/code-quality-budgets.json +++ b/quality/code-quality-budgets.json @@ -20,7 +20,7 @@ "owner": "dashboard shell hotspot" }, "apps/dashboard/src/i18n.ts": { - "maxLines": 1330, + "maxLines": 2156, "owner": "dashboard i18n hotspot" }, "apps/dashboard/src/styles.css": { @@ -40,6 +40,10 @@ "maxFunctionLines": 279, "owner": "runner shared test hotspot" }, + "scripts/dashboard-ux.ts": { + "maxLines": 1034, + "owner": "dashboard ux smoke hotspot" + }, "setup/migrate-room-registrations.test.ts": { "maxFunctionLines": 393, "owner": "setup test hotspot" @@ -64,10 +68,6 @@ "maxFunctionLines": 548, "owner": "agent runner test hotspot" }, - "src/channels/discord.test.ts": { - "maxFunctionLines": 816, - "owner": "discord test hotspot" - }, "src/codex-warmup.test.ts": { "maxFunctionLines": 277, "owner": "codex warmup test hotspot" diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 5f41e29..7c9c4b8 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -24,282 +24,8 @@ async function main() { try { const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/'; - - await runScenario( - 'settings nav keeps hash route stable', - browser, - baseUrl, - async (page, state) => { - await openSettings(page, baseUrl); - - const originalUrl = page.url(); - assert.equal(originalUrl.endsWith('/#/settings'), true); - assert.equal( - await page.locator('.settings-nav a[href^="#settings-"]').count(), - 0, - ); - - await page - .locator( - '.settings-nav button[data-settings-target="settings-codex"]', - ) - .click(); - await page.waitForTimeout(150); - assert.equal(page.url(), originalUrl); - await assertVisible(page.locator('.settings-panel')); - await assertVisible(page.locator('#settings-codex')); - - await openSettingsSection(page, 'settings-runtime'); - assert.equal(page.url(), originalUrl); - await assertVisible(page.locator('#settings-runtime')); - await page.getByRole('heading', { name: '방 선택' }).waitFor(); - const skillToggle = page - .locator( - '#settings-runtime .settings-toggle-row input[type="checkbox"]', - ) - .first(); - assert.equal(await skillToggle.isChecked(), true); - await skillToggle.click(); - assert.equal(await skillToggle.isChecked(), false); - assert.equal(state.roomSkillUpdates, 1); - - await page - .locator( - '.settings-nav button[data-settings-target="settings-accounts"]', - ) - .click(); - await page.waitForTimeout(150); - assert.equal(page.url(), originalUrl); - await assertVisible(page.locator('.settings-panel')); - await assertVisible(page.locator('#settings-accounts')); - - const sidebar = page.locator('.settings-sidebar'); - const navBoxBefore = await sidebar.boundingBox(); - await openSettingsSection(page, 'settings-general'); - await page.waitForTimeout(150); - const navBoxAfter = await sidebar.boundingBox(); - assert.ok(navBoxBefore && navBoxAfter); - assert.equal(navBoxBefore.y, navBoxAfter.y); - - await assertNoSeriousA11yViolations(page); - }, - ); - - await runScenario( - 'codex goal toggle persists and explains restart', - browser, - baseUrl, - async (page, state) => { - await openSettings(page, baseUrl); - await openSettingsSection(page, 'settings-codex'); - - const goalToggle = page.getByRole('checkbox', { name: /\/goal/ }); - await assertVisible(goalToggle); - assert.equal(await goalToggle.isChecked(), false); - - await goalToggle.click(); - await page - .getByText('저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.') - .waitFor(); - - assert.equal(await goalToggle.isChecked(), true); - assert.equal(state.codexFeatures.goals, true); - assert.equal(state.codexFeatureUpdates, 1); - - await assertNoSeriousA11yViolations(page); - }, - ); - - await runScenario( - 'restart action is singular and guarded', - browser, - baseUrl, - async (page, state) => { - const dialogMessages: string[] = []; - page.on('dialog', async (dialog) => { - dialogMessages.push(dialog.message()); - await dialog.accept(); - }); - - await openSettings(page, baseUrl); - - const restartButtons = page.getByRole('button', { - name: '스택 재시작', - }); - assert.equal(await restartButtons.count(), 1); - - await restartButtons.first().click(); - await page.waitForTimeout(250); - - assert.equal(dialogMessages.length, 1); - assert.equal(state.restartRequests, 1); - - await assertNoSeriousA11yViolations(page); - }, - ); - - await runScenario( - 'rooms surface user action badges without inbox navigation', - browser, - baseUrl, - async (page, state) => { - state.approvalAction = true; - state.ciWatcherFailures = 2; - - await page.goto(new URL('/#/inbox', baseUrl).toString(), { - waitUntil: 'networkidle', - }); - - await page.waitForURL(/#\/rooms$/); - await assertVisible(page.locator('#rooms .rooms-v2')); - assert.equal(await page.locator('a[href="#/inbox"]').count(), 0); - assert.equal(await page.locator('a[href="#/health"]').count(), 0); - await assertVisible(page.getByText(/승인|Approval/).first()); - await assertVisible(page.locator('.system-status-strip')); - assert.equal( - await page.getByRole('button', { name: 'Dismiss' }).count(), - 0, - ); - - // This scenario protects the Inbox information architecture. The - // accessibility scan stays scoped to Settings, where this UX suite - // currently has stable interactive coverage. - }, - ); - - await runScheduledBoardScenario(browser, baseUrl); - - await runScenario( - 'health route redirects to rooms and degraded state is conditional', - browser, - baseUrl, - async (page, state) => { - state.approvalAction = true; - - await page.goto(new URL('/#/rooms', baseUrl).toString(), { - waitUntil: 'networkidle', - }); - await assertVisible(page.locator('#rooms .rooms-v2')); - assert.equal(await page.locator('.system-status-strip').count(), 0); - - state.ciWatcherFailures = 2; - await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), { - waitUntil: 'networkidle', - }); - - await page.waitForURL(/#\/rooms$/); - await assertVisible(page.locator('#rooms .rooms-v2')); - await assertVisible(page.locator('.system-status-strip')); - assert.equal(await page.locator('#health').count(), 0); - assert.equal(await page.locator('a[href="#/health"]').count(), 0); - await assertVisible(page.getByText(/CI 실패|CI failure/).first()); - }, - ); - - await runMobileScenario( - 'mobile settings keeps nav and content aligned without horizontal scroll', - browser, - baseUrl, - async (page) => { - await openSettings(page, baseUrl); - await assertNoHorizontalOverflow(page); - - const nav = page.locator('.settings-nav-scroll'); - const section = page.locator('.settings-section').first(); - const navBox = await nav.boundingBox(); - const sectionBox = await section.boundingBox(); - assert.ok(navBox && sectionBox); - assert.equal(Math.round(navBox.x), Math.round(sectionBox.x)); - assert.equal(Math.round(navBox.width), Math.round(sectionBox.width)); - - const selectedTab = page.locator( - '.settings-nav button[aria-selected="true"]', - ); - const tabBox = await selectedTab.boundingBox(); - const subtitleBox = await selectedTab.locator('small').boundingBox(); - assert.ok(tabBox && subtitleBox); - assert.ok( - subtitleBox.y + subtitleBox.height <= tabBox.y + tabBox.height + 1, - 'tab subtitle should not be clipped', - ); - - await openSettingsSection(page, 'settings-models'); - await assertNoHorizontalOverflow(page); - await assertNoSeriousA11yViolations(page); - }, - ); - - await runMobileScenario( - 'mobile accounts use compact rows for multiple entries', - browser, - baseUrl, - async (page) => { - await openSettings(page, baseUrl); - await openSettingsSection(page, 'settings-accounts'); - await assertVisible(page.locator('#settings-accounts')); - - assert.equal(await page.locator('.settings-account-list').count(), 2); - assert.equal(await page.locator('.settings-account-row').count(), 6); - assert.equal(await page.locator('.settings-account-card').count(), 0); - - const listBox = await page - .locator('.settings-account-list') - .first() - .boundingBox(); - const rowBox = await page - .locator('.settings-account-row') - .first() - .boundingBox(); - assert.ok(listBox && rowBox); - assert.ok(rowBox.height <= 72, 'account row should stay compact'); - - await assertNoHorizontalOverflow(page); - }, - ); - - await runMobileScenario( - 'mobile primary views render without layout overflow', - browser, - baseUrl, - async (page) => { - for (const hash of [ - '#/rooms', - '#/scheduled', - '#/usage', - '#/settings', - ]) { - await page.goto(new URL(hash, baseUrl).toString(), { - waitUntil: 'networkidle', - }); - await assertNoHorizontalOverflow(page); - } - - await page.goto(new URL('/#/scheduled', baseUrl).toString(), { - waitUntil: 'networkidle', - }); - await assertVisible(page.locator('#scheduled .task-summary-bar')); - await assertVisible(page.locator('#scheduled .task-filter-tabs')); - }, - ); - - await runMobileScenario( - 'mobile scheduled board uses compact rows and filter tabs', - browser, - baseUrl, - async (page) => { - await page.goto(new URL('/#/scheduled', baseUrl).toString(), { - waitUntil: 'networkidle', - }); - await assertVisible(page.locator('#scheduled .task-summary-bar')); - await assertVisible(page.locator('#scheduled .task-filter-tabs')); - assert.equal(await page.locator('#scheduled .task-row').count(), 3); - - await page.getByRole('tab', { name: /예약|Scheduled/i }).click(); - assert.equal(await page.locator('#scheduled .task-row').count(), 1); - await assertNoHorizontalOverflow(page); - }, - ); - + await runDesktopDashboardScenarios(browser, baseUrl); + await runMobileDashboardScenarios(browser, baseUrl); console.log('dashboard:ux passed'); } finally { await browser.close(); @@ -307,6 +33,278 @@ async function main() { } } +async function runDesktopDashboardScenarios(browser: Browser, baseUrl: string) { + await runScenario( + 'settings nav keeps hash route stable', + browser, + baseUrl, + async (page, state) => { + await openSettings(page, baseUrl); + + const originalUrl = page.url(); + assert.equal(originalUrl.endsWith('/#/settings'), true); + assert.equal( + await page.locator('.settings-nav a[href^="#settings-"]').count(), + 0, + ); + + await page + .locator('.settings-nav button[data-settings-target="settings-codex"]') + .click(); + await page.waitForTimeout(150); + assert.equal(page.url(), originalUrl); + await assertVisible(page.locator('.settings-panel')); + await assertVisible(page.locator('#settings-codex')); + + await openSettingsSection(page, 'settings-runtime'); + assert.equal(page.url(), originalUrl); + await assertVisible(page.locator('#settings-runtime')); + await page.getByRole('heading', { name: '방 선택' }).waitFor(); + const skillToggle = page + .locator( + '#settings-runtime .settings-toggle-row input[type="checkbox"]', + ) + .first(); + assert.equal(await skillToggle.isChecked(), true); + await skillToggle.click(); + assert.equal(await skillToggle.isChecked(), false); + assert.equal(state.roomSkillUpdates, 1); + + await page + .locator( + '.settings-nav button[data-settings-target="settings-accounts"]', + ) + .click(); + await page.waitForTimeout(150); + assert.equal(page.url(), originalUrl); + await assertVisible(page.locator('.settings-panel')); + await assertVisible(page.locator('#settings-accounts')); + + const sidebar = page.locator('.settings-sidebar'); + const navBoxBefore = await sidebar.boundingBox(); + await openSettingsSection(page, 'settings-general'); + await page.waitForTimeout(150); + const navBoxAfter = await sidebar.boundingBox(); + assert.ok(navBoxBefore && navBoxAfter); + assert.equal(navBoxBefore.y, navBoxAfter.y); + + await assertNoSeriousA11yViolations(page); + }, + ); + + await runScenario( + 'codex goal toggle persists and explains next-run apply', + browser, + baseUrl, + async (page, state) => { + await openSettings(page, baseUrl); + await openSettingsSection(page, 'settings-codex'); + + const goalToggle = page.getByRole('checkbox', { name: /\/goal/ }); + await assertVisible(goalToggle); + assert.equal(await goalToggle.isChecked(), false); + + await goalToggle.click(); + await page + .getByText('스택 재시작 없이 다음 Codex 작업부터 적용됩니다.') + .waitFor(); + + assert.equal(await goalToggle.isChecked(), true); + assert.equal(state.codexFeatures.goals, true); + assert.equal(state.codexFeatureUpdates, 1); + + await assertNoSeriousA11yViolations(page); + }, + ); + + await runScenario( + 'restart action is singular and guarded', + browser, + baseUrl, + async (page, state) => { + const dialogMessages: string[] = []; + page.on('dialog', async (dialog) => { + dialogMessages.push(dialog.message()); + await dialog.accept(); + }); + + await openSettings(page, baseUrl); + + const restartButtons = page.getByRole('button', { + name: '스택 재시작', + }); + assert.equal(await restartButtons.count(), 1); + + await restartButtons.first().click(); + await page.waitForTimeout(250); + + assert.equal(dialogMessages.length, 1); + assert.equal(state.restartRequests, 1); + + await assertNoSeriousA11yViolations(page); + }, + ); + + await runScenario( + 'rooms surface user action badges without inbox navigation', + browser, + baseUrl, + async (page, state) => { + state.approvalAction = true; + state.ciWatcherFailures = 2; + + await page.goto(new URL('/#/inbox', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + + await page.waitForURL(/#\/rooms$/); + await assertVisible(page.locator('#rooms .rooms-v2')); + assert.equal(await page.locator('a[href="#/inbox"]').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); + await assertVisible(page.getByText(/승인|Approval/).first()); + await assertVisible(page.locator('.system-status-strip')); + assert.equal( + await page.getByRole('button', { name: 'Dismiss' }).count(), + 0, + ); + + // This scenario protects the Inbox information architecture. The + // accessibility scan stays scoped to Settings, where this UX suite + // currently has stable interactive coverage. + }, + ); + + await runScheduledBoardScenario(browser, baseUrl); + + await runScenario( + 'health route redirects to rooms and degraded state is conditional', + browser, + baseUrl, + async (page, state) => { + state.approvalAction = true; + + await page.goto(new URL('/#/rooms', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertVisible(page.locator('#rooms .rooms-v2')); + assert.equal(await page.locator('.system-status-strip').count(), 0); + + state.ciWatcherFailures = 2; + await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + + await page.waitForURL(/#\/rooms$/); + await assertVisible(page.locator('#rooms .rooms-v2')); + await assertVisible(page.locator('.system-status-strip')); + assert.equal(await page.locator('#health').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); + await assertVisible(page.getByText(/CI 실패|CI failure/).first()); + }, + ); +} + +async function runMobileDashboardScenarios(browser: Browser, baseUrl: string) { + await runMobileScenario( + 'mobile settings keeps nav and content aligned without horizontal scroll', + browser, + baseUrl, + async (page) => { + await openSettings(page, baseUrl); + await assertNoHorizontalOverflow(page); + + const nav = page.locator('.settings-nav-scroll'); + const section = page.locator('.settings-section').first(); + const navBox = await nav.boundingBox(); + const sectionBox = await section.boundingBox(); + assert.ok(navBox && sectionBox); + assert.equal(Math.round(navBox.x), Math.round(sectionBox.x)); + assert.equal(Math.round(navBox.width), Math.round(sectionBox.width)); + + const selectedTab = page.locator( + '.settings-nav button[aria-selected="true"]', + ); + const tabBox = await selectedTab.boundingBox(); + const subtitleBox = await selectedTab.locator('small').boundingBox(); + assert.ok(tabBox && subtitleBox); + assert.ok( + subtitleBox.y + subtitleBox.height <= tabBox.y + tabBox.height + 1, + 'tab subtitle should not be clipped', + ); + + await openSettingsSection(page, 'settings-models'); + await assertNoHorizontalOverflow(page); + await assertNoSeriousA11yViolations(page); + }, + ); + + await runMobileScenario( + 'mobile accounts use compact rows for multiple entries', + browser, + baseUrl, + async (page) => { + await openSettings(page, baseUrl); + await openSettingsSection(page, 'settings-accounts'); + await assertVisible(page.locator('#settings-accounts')); + + assert.equal(await page.locator('.settings-account-list').count(), 2); + assert.equal(await page.locator('.settings-account-row').count(), 6); + assert.equal(await page.locator('.settings-account-card').count(), 0); + + const listBox = await page + .locator('.settings-account-list') + .first() + .boundingBox(); + const rowBox = await page + .locator('.settings-account-row') + .first() + .boundingBox(); + assert.ok(listBox && rowBox); + assert.ok(rowBox.height <= 72, 'account row should stay compact'); + + await assertNoHorizontalOverflow(page); + }, + ); + + await runMobileScenario( + 'mobile primary views render without layout overflow', + browser, + baseUrl, + async (page) => { + for (const hash of ['#/rooms', '#/scheduled', '#/usage', '#/settings']) { + await page.goto(new URL(hash, baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertNoHorizontalOverflow(page); + } + + await page.goto(new URL('/#/scheduled', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertVisible(page.locator('#scheduled .task-summary-bar')); + await assertVisible(page.locator('#scheduled .task-filter-tabs')); + }, + ); + + await runMobileScenario( + 'mobile scheduled board uses compact rows and filter tabs', + browser, + baseUrl, + async (page) => { + await page.goto(new URL('/#/scheduled', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertVisible(page.locator('#scheduled .task-summary-bar')); + await assertVisible(page.locator('#scheduled .task-filter-tabs')); + assert.equal(await page.locator('#scheduled .task-row').count(), 3); + + await page.getByRole('tab', { name: /예약|Scheduled/i }).click(); + assert.equal(await page.locator('#scheduled .task-row').count(), 1); + await assertNoHorizontalOverflow(page); + }, + ); +} + async function runScheduledBoardScenario(browser: Browser, baseUrl: string) { await runScenario( 'scheduled board surfaces next task without empty lanes', @@ -386,9 +384,9 @@ async function runScenarioWithViewport( isMobile: viewport.width <= 640, hasTouch: viewport.width <= 640, }); - await context.addInitScript(() => { - window.localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko'); - }); + await context.addInitScript( + "localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko')", + ); const state = createMockApiState(); await context.route('**/api/**', (route) => handleMockApi(route, state)); const page = await context.newPage(); @@ -458,14 +456,17 @@ async function assertNoSeriousA11yViolations(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 tolerance = 1; return { document: doc.scrollWidth - doc.clientWidth > tolerance, body: document.body.scrollWidth - document.body.clientWidth > tolerance, }; - }); + })()`); assert.equal( overflow.document, false, @@ -503,11 +504,7 @@ async function handleMockApi(route: Route, state: MockApiState) { } if (method === 'GET' && url.pathname === '/api/settings/models') { - await fulfillJson(route, { - owner: { model: 'codex', effort: 'high' }, - reviewer: { model: 'claude', effort: 'medium' }, - arbiter: { model: 'claude', effort: 'medium' }, - }); + await fulfillJson(route, mockModelSettings()); return; } @@ -542,130 +539,18 @@ async function handleMockApi(route: Route, state: MockApiState) { } if (method === 'GET' && url.pathname === '/api/settings/moa') { - await fulfillJson(route, { - enabled: true, - referenceModels: ['kimi', 'glm'], - models: [ - { - name: 'kimi', - enabled: true, - model: 'kimi-k2', - baseUrl: 'https://api.moonshot.ai', - apiFormat: 'anthropic', - apiKeyConfigured: true, - lastStatus: null, - }, - { - name: 'glm', - enabled: true, - model: 'glm-4.6', - baseUrl: 'https://open.bigmodel.cn/api/paas/v4', - apiFormat: 'anthropic', - apiKeyConfigured: true, - lastStatus: null, - }, - ], - }); + await fulfillJson(route, mockMoaSettings()); return; } if (method === 'GET' && url.pathname === '/api/settings/accounts') { - await fulfillJson(route, { - claude: [ - { - index: 0, - expiresAt: null, - scopes: [], - subscriptionType: 'max', - rateLimitTier: 'default', - exists: true, - }, - { - index: 1, - expiresAt: null, - scopes: [], - subscriptionType: 'pro', - rateLimitTier: 'default', - exists: true, - }, - { - index: 2, - expiresAt: null, - scopes: [], - subscriptionType: 'team', - rateLimitTier: 'default', - exists: true, - }, - ], - codex: [ - { - index: 0, - accountId: 'acct_test', - email: 'codex@example.com', - planType: 'plus', - subscriptionUntil: null, - subscriptionLastChecked: '2026-01-01T00:00:00.000Z', - subscriptionSource: null, - liveStatus: { - 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, - }, - { - index: 1, - accountId: 'acct_backup', - email: 'backup@example.com', - planType: 'plus', - subscriptionUntil: '2026-06-01T00:00:00.000Z', - subscriptionLastChecked: '2026-01-01T00:00:00.000Z', - exists: true, - }, - { - index: 2, - accountId: 'acct_sandbox', - email: 'sandbox@example.com', - planType: 'free', - subscriptionUntil: null, - subscriptionLastChecked: '2026-01-01T00:00:00.000Z', - exists: true, - }, - ], - codexCurrentIndex: 0, - }); + await fulfillJson(route, mockAccounts()); return; } if (method === 'POST' && url.pathname === '/api/services/stack/actions') { state.restartRequests += 1; - await fulfillJson(route, { - ok: true, - restart: { - id: 'restart-test', - target: 'stack', - requestedAt: new Date(0).toISOString(), - completedAt: null, - status: 'running', - services: ['ejclaw'], - }, - }); + await fulfillJson(route, mockStackRestart()); return; } @@ -676,6 +561,147 @@ async function handleMockApi(route: Route, state: MockApiState) { ); } +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, + referenceModels: ['kimi', 'glm'], + models: [ + { + name: 'kimi', + enabled: true, + model: 'kimi-k2', + baseUrl: 'https://api.moonshot.ai', + apiFormat: 'anthropic', + apiKeyConfigured: true, + lastStatus: null, + }, + { + name: 'glm', + enabled: true, + model: 'glm-4.6', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + apiFormat: 'anthropic', + apiKeyConfigured: true, + lastStatus: null, + }, + ], + }; +} + +function mockAccounts() { + return { + claude: [ + { + index: 0, + expiresAt: null, + scopes: [], + subscriptionType: 'max', + rateLimitTier: 'default', + exists: true, + }, + { + index: 1, + expiresAt: null, + scopes: [], + subscriptionType: 'pro', + rateLimitTier: 'default', + exists: true, + }, + { + index: 2, + expiresAt: null, + scopes: [], + subscriptionType: 'team', + rateLimitTier: 'default', + exists: true, + }, + ], + codex: [ + { + index: 0, + accountId: 'acct_test', + email: 'codex@example.com', + planType: 'plus', + subscriptionUntil: null, + subscriptionLastChecked: '2026-01-01T00:00:00.000Z', + subscriptionSource: null, + liveStatus: mockCodexLiveStatus(), + exists: true, + }, + { + index: 1, + accountId: 'acct_backup', + email: 'backup@example.com', + planType: 'plus', + subscriptionUntil: '2026-06-01T00:00:00.000Z', + subscriptionLastChecked: '2026-01-01T00:00:00.000Z', + exists: true, + }, + { + index: 2, + accountId: 'acct_sandbox', + email: 'sandbox@example.com', + planType: 'free', + subscriptionUntil: null, + subscriptionLastChecked: '2026-01-01T00:00:00.000Z', + exists: true, + }, + ], + codexCurrentIndex: 0, + }; +} + +function mockCodexLiveStatus() { + return { + 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, + restart: { + id: 'restart-test', + target: 'stack', + requestedAt: new Date(0).toISOString(), + completedAt: null, + status: 'running', + services: ['ejclaw'], + }, + }; +} + async function handleMockRoomSkillRoute( route: Route, state: MockApiState, diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 246ca57..0912fe8 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -233,830 +233,820 @@ async function triggerMessage(message: any) { // --- Tests --- -describe('DiscordChannel', () => { +beforeEach(() => { + vi.clearAllMocks(); + hasReviewerLeaseMock.mockReturnValue(false); + loginShouldRejectRef.value = false; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('channel registration', () => { + it('warns when the canonical owner token is not configured', () => { + const ownerFactory = registeredChannelFactories.get('discord'); + + expect(ownerFactory).toBeTypeOf('function'); + expect(ownerFactory?.(createTestOpts() as any)).toBeNull(); + expect(logger.warn).toHaveBeenCalledWith( + 'Discord: DISCORD_OWNER_BOT_TOKEN not set', + ); + }); +}); + +// --- Connection lifecycle --- + +describe('connection lifecycle', () => { + it('resolves connect() when client is ready', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + + expect(channel.isConnected()).toBe(true); + }); + + it('rejects connect() when login fails', async () => { + loginShouldRejectRef.value = true; + const opts = createTestOpts(); + const channel = new DiscordChannel('bad-token', opts); + + await expect(channel.connect()).rejects.toThrow( + 'An invalid token was provided.', + ); + expect(channel.isConnected()).toBe(false); + }); + + it('registers message handlers on connect', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + + expect(currentClient().eventHandlers.has('messageCreate')).toBe(true); + expect(currentClient().eventHandlers.has('error')).toBe(true); + expect(currentClient().eventHandlers.has('ready')).toBe(true); + }); + + it('disconnects cleanly', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + await channel.connect(); + expect(channel.isConnected()).toBe(true); + + await channel.disconnect(); + expect(channel.isConnected()).toBe(false); + }); + + it('isConnected() returns false before connect', () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + expect(channel.isConnected()).toBe(false); + }); +}); + +// --- Text message handling --- + +describe('text message handling', () => { + it('delivers message for registered channel', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello everyone', + guildName: 'Test Server', + channelName: 'general', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'Test Server #general', + 'discord', + true, + ); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + id: 'msg_001', + chat_jid: 'dc:1234567890123456', + sender: '55512345', + sender_name: 'Alice', + content: 'Hello everyone', + is_from_me: false, + }), + ); + }); + + it('only emits metadata for unregistered channels', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + channelId: '9999999999999999', + content: 'Unknown channel', + guildName: 'Other Server', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:9999999999999999', + expect.any(String), + expect.any(String), + 'discord', + true, + ); + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores its own bot messages', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + authorId: '999888777', + isBot: true, + content: 'I am the connected bot', + }); + await triggerMessage(msg); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('ignores other bot messages in normal rooms', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + authorId: '111222333', + isBot: true, + content: 'I am another bot', + }); + await triggerMessage(msg); + + expect(opts.onMessage).not.toHaveBeenCalled(); + }); + + it('delivers other bot messages in paired rooms', async () => { + hasReviewerLeaseMock.mockReturnValue(true); + + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + authorId: '111222333', + isBot: true, + content: 'I am another bot', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: 'I am another bot', + is_bot_message: true, + }), + ); + }); + + it('uses member displayName when available (server nickname)', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hi', + memberDisplayName: 'Alice Nickname', + authorDisplayName: 'Alice Global', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ sender_name: 'Alice Nickname' }), + ); + }); + + it('falls back to author displayName when no member', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hi', + memberDisplayName: undefined, + authorDisplayName: 'Alice Global', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ sender_name: 'Alice Global' }), + ); + }); + + it('uses sender name for DM chats (no guild)', async () => { + const opts = createTestOpts({ + roomBindings: vi.fn(() => ({ + 'dc:1234567890123456': { + name: 'DM', + folder: 'dm', + trigger: '@Andy', + added_at: '2024-01-01T00:00:00.000Z', + }, + })), + }); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello', + guildName: undefined, + authorDisplayName: 'Alice', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'Alice', + 'discord', + false, + ); + }); + + it('uses guild name + channel name for server messages', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'Hello', + guildName: 'My Server', + channelName: 'bot-chat', + }); + await triggerMessage(msg); + + expect(opts.onChatMetadata).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.any(String), + 'My Server #bot-chat', + 'discord', + true, + ); + }); +}); + +// --- bot mention handling --- + +describe('bot mention handling', () => { + it('passes through <@botId> mentions without rewriting them', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '<@999888777> what time is it?', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '<@999888777> what time is it?', + }), + ); + }); + + it('leaves mixed text and mentions untouched', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '@Andy hello <@999888777>', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + // Should NOT prepend @Andy — already starts with trigger + // But the <@botId> should still be stripped + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '@Andy hello <@999888777>', + }), + ); + }); + + it('does not translate when bot is not mentioned', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'hello everyone', + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: 'hello everyone', + }), + ); + }); + + it('passes through <@!botId> nickname mentions without rewriting them', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: '<@!999888777> check this', + mentionsBotId: true, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '<@!999888777> check this', + }), + ); + }); +}); + +// --- Attachments --- + +describe('attachments', () => { + let originalFetch: typeof globalThis.fetch; + beforeEach(() => { - vi.clearAllMocks(); - hasReviewerLeaseMock.mockReturnValue(false); - loginShouldRejectRef.value = false; + originalFetch = globalThis.fetch; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + text: () => Promise.resolve('Hello from text file'), + }), + ); }); afterEach(() => { - vi.restoreAllMocks(); + globalThis.fetch = originalFetch; }); - describe('channel registration', () => { - it('warns when the canonical owner token is not configured', () => { - const ownerFactory = registeredChannelFactories.get('discord'); - - expect(ownerFactory).toBeTypeOf('function'); - expect(ownerFactory?.(createTestOpts() as any)).toBeNull(); - expect(logger.warn).toHaveBeenCalledWith( - 'Discord: DISCORD_OWNER_BOT_TOKEN not set', - ); - }); - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('resolves connect() when client is ready', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - - expect(channel.isConnected()).toBe(true); - }); - - it('rejects connect() when login fails', async () => { - loginShouldRejectRef.value = true; - const opts = createTestOpts(); - const channel = new DiscordChannel('bad-token', opts); - - await expect(channel.connect()).rejects.toThrow( - 'An invalid token was provided.', - ); - expect(channel.isConnected()).toBe(false); - }); - - it('registers message handlers on connect', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - - expect(currentClient().eventHandlers.has('messageCreate')).toBe(true); - expect(currentClient().eventHandlers.has('error')).toBe(true); - expect(currentClient().eventHandlers.has('ready')).toBe(true); - }); - - it('disconnects cleanly', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - await channel.connect(); - expect(channel.isConnected()).toBe(true); - - await channel.disconnect(); - expect(channel.isConnected()).toBe(false); - }); - - it('isConnected() returns false before connect', () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - expect(channel.isConnected()).toBe(false); - }); - }); - - // --- Text message handling --- - - describe('text message handling', () => { - it('delivers message for registered channel', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello everyone', - guildName: 'Test Server', - channelName: 'general', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'Test Server #general', - 'discord', - true, - ); - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - id: 'msg_001', - chat_jid: 'dc:1234567890123456', - sender: '55512345', - sender_name: 'Alice', - content: 'Hello everyone', - is_from_me: false, - }), - ); - }); - - it('only emits metadata for unregistered channels', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - channelId: '9999999999999999', - content: 'Unknown channel', - guildName: 'Other Server', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:9999999999999999', - expect.any(String), - expect.any(String), - 'discord', - true, - ); - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores its own bot messages', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - authorId: '999888777', - isBot: true, - content: 'I am the connected bot', - }); - await triggerMessage(msg); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('ignores other bot messages in normal rooms', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - authorId: '111222333', - isBot: true, - content: 'I am another bot', - }); - await triggerMessage(msg); - - expect(opts.onMessage).not.toHaveBeenCalled(); - }); - - it('delivers other bot messages in paired rooms', async () => { - hasReviewerLeaseMock.mockReturnValue(true); - - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - authorId: '111222333', - isBot: true, - content: 'I am another bot', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: 'I am another bot', - is_bot_message: true, - }), - ); - }); - - it('uses member displayName when available (server nickname)', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hi', - memberDisplayName: 'Alice Nickname', - authorDisplayName: 'Alice Global', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ sender_name: 'Alice Nickname' }), - ); - }); - - it('falls back to author displayName when no member', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hi', - memberDisplayName: undefined, - authorDisplayName: 'Alice Global', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ sender_name: 'Alice Global' }), - ); - }); - - it('uses sender name for DM chats (no guild)', async () => { - const opts = createTestOpts({ - roomBindings: vi.fn(() => ({ - 'dc:1234567890123456': { - name: 'DM', - folder: 'dm', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', - }, - })), - }); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello', - guildName: undefined, - authorDisplayName: 'Alice', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'Alice', - 'discord', - false, - ); - }); - - it('uses guild name + channel name for server messages', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'Hello', - guildName: 'My Server', - channelName: 'bot-chat', - }); - await triggerMessage(msg); - - expect(opts.onChatMetadata).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.any(String), - 'My Server #bot-chat', - 'discord', - true, - ); - }); - }); - - // --- bot mention handling --- - - describe('bot mention handling', () => { - it('passes through <@botId> mentions without rewriting them', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '<@999888777> what time is it?', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '<@999888777> what time is it?', - }), - ); - }); - - it('leaves mixed text and mentions untouched', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '@Andy hello <@999888777>', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - // Should NOT prepend @Andy — already starts with trigger - // But the <@botId> should still be stripped - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '@Andy hello <@999888777>', - }), - ); - }); - - it('does not translate when bot is not mentioned', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'hello everyone', - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: 'hello everyone', - }), - ); - }); - - it('passes through <@!botId> nickname mentions without rewriting them', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: '<@!999888777> check this', - mentionsBotId: true, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '<@!999888777> check this', - }), - ); - }); - }); - - // --- Attachments --- - - describe('attachments', () => { - let originalFetch: typeof globalThis.fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), - text: () => Promise.resolve('Hello from text file'), - }), - ); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('stores image attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - [ - 'att1', - { - id: 'att1', - name: 'photo.png', - contentType: 'image/png', - url: 'https://cdn.example.com/photo.png', - }, - ], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: expect.stringMatching(/^\[Image: .+\.png\]$/), - }), - ); - }); - - it('stores video attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Video: clip.mp4]', - }), - ); - }); - - it('stores file attachment with placeholder', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - ['att1', { name: 'report.pdf', contentType: 'application/pdf' }], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[File: report.pdf]', - }), - ); - }); - - it('includes text content with attachments', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - [ - 'att1', - { - id: 'att1', - name: 'photo.jpg', - contentType: 'image/jpeg', - url: 'https://cdn.example.com/photo.jpg', - }, - ], - ]); - const msg = createMessage({ - content: 'Check this out', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: expect.stringMatching( - /^Check this out\n\[Image: .+\.jpg\]$/, - ), - }), - ); - }); - - it('handles multiple attachments', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const attachments = new Map([ - [ - 'att1', - { - id: 'att1', - name: 'a.png', - contentType: 'image/png', - url: 'https://cdn.example.com/a.png', - }, - ], - [ - 'att2', - { - id: 'att2', - name: 'b.txt', - contentType: 'text/plain', - url: 'https://cdn.example.com/b.txt', - }, - ], - ]); - const msg = createMessage({ - content: '', - attachments, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: expect.stringMatching( - /^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/, - ), - }), - ); - }); - }); - - // --- Reply context --- - - describe('reply context', () => { - it('includes reply author in content', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const msg = createMessage({ - content: 'I agree with that', - reference: { messageId: 'original_msg_id' }, - guildName: 'Server', - }); - await triggerMessage(msg); - - expect(opts.onMessage).toHaveBeenCalledWith( - 'dc:1234567890123456', - expect.objectContaining({ - content: '[Reply to Bob] I agree with that', - }), - ); - }); - }); - - // --- sendMessage --- - - describe('sendMessage', () => { - it('sends message via channel', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('dc:1234567890123456', 'Hello'); - - await currentClient().channels.fetch('1234567890123456'); - expect(currentClient().channels.fetch).toHaveBeenCalledWith( - '1234567890123456', - ); - }); - - it('strips dc: prefix from JID', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.sendMessage('dc:9876543210', 'Test'); - - expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210'); - }); - - it('propagates send failure to the caller', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - currentClient().channels.fetch.mockRejectedValueOnce( - new Error('Channel not found'), - ); - - await expect( - channel.sendMessage('dc:1234567890123456', 'Will fail'), - ).rejects.toThrow('Channel not found'); - }); - - it('rejects when client is not initialized', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - // Don't connect — client is null - await expect( - channel.sendMessage('dc:1234567890123456', 'No client'), - ).rejects.toThrow('Discord client not initialized'); - }); - - it('splits messages exceeding 2000 characters', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const mockChannel = { - send: vi.fn().mockResolvedValue(undefined), - sendTyping: vi.fn(), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - const longText = 'x'.repeat(3000); - await channel.sendMessage('dc:1234567890123456', longText); - - expect(mockChannel.send).toHaveBeenCalledTimes(2); - expect(mockChannel.send).toHaveBeenNthCalledWith(1, { - content: 'x'.repeat(2000), - files: undefined, - flags: 1 << 2, - }); - expect(mockChannel.send).toHaveBeenNthCalledWith(2, { - content: 'x'.repeat(1000), - files: undefined, - flags: 1 << 2, - }); - }); - - it('sends structured attachments as Discord files', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - const { dir, filePath } = createTempPng('structured.png'); - const mockChannel = { - send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), - sendTyping: vi.fn(), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - await channel.sendMessage( - 'dc:1234567890123456', - '이미지를 생성했습니다.', + it('stores image attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + [ + 'att1', { - attachments: [ - { path: filePath, name: 'result.png', mime: 'image/png' }, - ], + id: 'att1', + name: 'photo.png', + contentType: 'image/png', + url: 'https://cdn.example.com/photo.png', }, - ); - - expect(mockChannel.send).toHaveBeenCalledWith({ - content: '이미지를 생성했습니다.', - files: [{ attachment: filePath, name: 'result.png' }], - flags: 1 << 2, - }); - fs.rmSync(dir, { recursive: true, force: true }); + ], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', }); + await triggerMessage(msg); - it('uses legacy image tags as Discord attachment fallback', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - const { dir, filePath } = createTempPng('screenshot.png'); - const mockChannel = { - send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), - sendTyping: vi.fn(), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - await channel.sendMessage( - 'dc:1234567890123456', - `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`, - ); - - expect(mockChannel.send).toHaveBeenCalledWith({ - content: '스크린샷입니다.', - files: [{ attachment: filePath, name: 'screenshot.png' }], - flags: 1 << 2, - }); - fs.rmSync(dir, { recursive: true, force: true }); - }); - - it('logs channel name and Discord message ids after sending', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const mockChannel = { - send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), - sendTyping: vi.fn(), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - await channel.sendMessage('dc:1234567890123456', 'Hello'); - - expect(logger.info).toHaveBeenCalledWith( - expect.objectContaining({ - jid: 'dc:1234567890123456', - channelName: 'discord', - deliveryMode: 'send', - chunkCount: 1, - messageId: 'discord-message-1', - messageIds: ['discord-message-1'], - }), - 'Discord message sent', - ); - }); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: expect.stringMatching(/^\[Image: .+\.png\]$/), + }), + ); }); - // --- ownsJid --- + it('stores video attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); - describe('ownsJid', () => { - it('owns dc: JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('dc:1234567890123456')).toBe(true); + const attachments = new Map([ + ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', }); + await triggerMessage(msg); - it('can be configured as an outbound-only role bot', () => { - const channel = new DiscordChannel( - 'test-token', - createTestOpts(), - undefined, - 'discord-review', - false, - false, - ); - expect(channel.ownsJid('dc:1234567890123456')).toBe(false); - }); - - it('does not own WhatsApp group JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('12345@g.us')).toBe(false); - }); - - it('does not own Telegram JIDs', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('tg:123456789')).toBe(false); - }); - - it('does not own unknown JID formats', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.ownsJid('random-string')).toBe(false); - }); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Video: clip.mp4]', + }), + ); }); - // --- setTyping --- + it('stores file attachment with placeholder', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); - describe('setTyping', () => { - it('sends typing indicator when isTyping is true', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - const mockChannel = { - send: vi.fn(), - sendTyping: vi.fn().mockResolvedValue(undefined), - }; - currentClient().channels.fetch.mockResolvedValue(mockChannel); - - await channel.setTyping('dc:1234567890123456', true); - - expect(mockChannel.sendTyping).toHaveBeenCalled(); + const attachments = new Map([ + ['att1', { name: 'report.pdf', contentType: 'application/pdf' }], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', }); + await triggerMessage(msg); - it('does nothing when isTyping is false', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - await channel.setTyping('dc:1234567890123456', false); - - // channels.fetch should NOT be called - expect(currentClient().channels.fetch).not.toHaveBeenCalled(); - }); - - it('does not send stale typing after typing was disabled mid-flight', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - await channel.connect(); - - let resolveFetch!: (value: unknown) => void; - const fetchPromise = new Promise((resolve) => { - resolveFetch = resolve; - }); - const mockChannel = { - send: vi.fn(), - sendTyping: vi.fn().mockResolvedValue(undefined), - }; - - currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise); - - const typingPromise = channel.setTyping('dc:1234567890123456', true); - await Promise.resolve(); - await channel.setTyping('dc:1234567890123456', false); - resolveFetch(mockChannel); - await typingPromise; - - expect(mockChannel.sendTyping).not.toHaveBeenCalled(); - }); - - it('does nothing when client is not initialized', async () => { - const opts = createTestOpts(); - const channel = new DiscordChannel('test-token', opts); - - // Don't connect - await channel.setTyping('dc:1234567890123456', true); - - // No error - }); + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[File: report.pdf]', + }), + ); }); - // --- Channel properties --- + it('includes text content with attachments', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); - describe('channel properties', () => { - it('has name "discord"', () => { - const channel = new DiscordChannel('test-token', createTestOpts()); - expect(channel.name).toBe('discord'); + const attachments = new Map([ + [ + 'att1', + { + id: 'att1', + name: 'photo.jpg', + contentType: 'image/jpeg', + url: 'https://cdn.example.com/photo.jpg', + }, + ], + ]); + const msg = createMessage({ + content: 'Check this out', + attachments, + guildName: 'Server', }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/), + }), + ); + }); + + it('handles multiple attachments', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const attachments = new Map([ + [ + 'att1', + { + id: 'att1', + name: 'a.png', + contentType: 'image/png', + url: 'https://cdn.example.com/a.png', + }, + ], + [ + 'att2', + { + id: 'att2', + name: 'b.txt', + contentType: 'text/plain', + url: 'https://cdn.example.com/b.txt', + }, + ], + ]); + const msg = createMessage({ + content: '', + attachments, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: expect.stringMatching( + /^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/, + ), + }), + ); + }); +}); + +// --- Reply context --- + +describe('reply context', () => { + it('includes reply author in content', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const msg = createMessage({ + content: 'I agree with that', + reference: { messageId: 'original_msg_id' }, + guildName: 'Server', + }); + await triggerMessage(msg); + + expect(opts.onMessage).toHaveBeenCalledWith( + 'dc:1234567890123456', + expect.objectContaining({ + content: '[Reply to Bob] I agree with that', + }), + ); + }); +}); + +// --- sendMessage --- + +describe('sendMessage', () => { + it('sends message via channel', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('dc:1234567890123456', 'Hello'); + + await currentClient().channels.fetch('1234567890123456'); + expect(currentClient().channels.fetch).toHaveBeenCalledWith( + '1234567890123456', + ); + }); + + it('strips dc: prefix from JID', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.sendMessage('dc:9876543210', 'Test'); + + expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210'); + }); + + it('propagates send failure to the caller', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + currentClient().channels.fetch.mockRejectedValueOnce( + new Error('Channel not found'), + ); + + await expect( + channel.sendMessage('dc:1234567890123456', 'Will fail'), + ).rejects.toThrow('Channel not found'); + }); + + it('rejects when client is not initialized', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + // Don't connect — client is null + await expect( + channel.sendMessage('dc:1234567890123456', 'No client'), + ).rejects.toThrow('Discord client not initialized'); + }); + + it('splits messages exceeding 2000 characters', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const mockChannel = { + send: vi.fn().mockResolvedValue(undefined), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + const longText = 'x'.repeat(3000); + await channel.sendMessage('dc:1234567890123456', longText); + + expect(mockChannel.send).toHaveBeenCalledTimes(2); + expect(mockChannel.send).toHaveBeenNthCalledWith(1, { + content: 'x'.repeat(2000), + files: undefined, + flags: 1 << 2, + }); + expect(mockChannel.send).toHaveBeenNthCalledWith(2, { + content: 'x'.repeat(1000), + files: undefined, + flags: 1 << 2, + }); + }); + + it('sends structured attachments as Discord files', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('structured.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', { + attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }], + }); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '이미지를 생성했습니다.', + files: [{ attachment: filePath, name: 'result.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses legacy image tags as Discord attachment fallback', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('screenshot.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`, + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '스크린샷입니다.', + files: [{ attachment: filePath, name: 'screenshot.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('logs channel name and Discord message ids after sending', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage('dc:1234567890123456', 'Hello'); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + jid: 'dc:1234567890123456', + channelName: 'discord', + deliveryMode: 'send', + chunkCount: 1, + messageId: 'discord-message-1', + messageIds: ['discord-message-1'], + }), + 'Discord message sent', + ); + }); +}); + +// --- ownsJid --- + +describe('ownsJid', () => { + it('owns dc: JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('dc:1234567890123456')).toBe(true); + }); + + it('can be configured as an outbound-only role bot', () => { + const channel = new DiscordChannel( + 'test-token', + createTestOpts(), + undefined, + 'discord-review', + false, + false, + ); + expect(channel.ownsJid('dc:1234567890123456')).toBe(false); + }); + + it('does not own WhatsApp group JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('12345@g.us')).toBe(false); + }); + + it('does not own Telegram JIDs', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('tg:123456789')).toBe(false); + }); + + it('does not own unknown JID formats', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.ownsJid('random-string')).toBe(false); + }); +}); + +// --- setTyping --- + +describe('setTyping', () => { + it('sends typing indicator when isTyping is true', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + const mockChannel = { + send: vi.fn(), + sendTyping: vi.fn().mockResolvedValue(undefined), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.setTyping('dc:1234567890123456', true); + + expect(mockChannel.sendTyping).toHaveBeenCalled(); + }); + + it('does nothing when isTyping is false', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + await channel.setTyping('dc:1234567890123456', false); + + // channels.fetch should NOT be called + expect(currentClient().channels.fetch).not.toHaveBeenCalled(); + }); + + it('does not send stale typing after typing was disabled mid-flight', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + + let resolveFetch!: (value: unknown) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + const mockChannel = { + send: vi.fn(), + sendTyping: vi.fn().mockResolvedValue(undefined), + }; + + currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise); + + const typingPromise = channel.setTyping('dc:1234567890123456', true); + await Promise.resolve(); + await channel.setTyping('dc:1234567890123456', false); + resolveFetch(mockChannel); + await typingPromise; + + expect(mockChannel.sendTyping).not.toHaveBeenCalled(); + }); + + it('does nothing when client is not initialized', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + + // Don't connect + await channel.setTyping('dc:1234567890123456', true); + + // No error + }); +}); + +// --- Channel properties --- + +describe('channel properties', () => { + it('has name "discord"', () => { + const channel = new DiscordChannel('test-token', createTestOpts()); + expect(channel.name).toBe('discord'); }); });