fix: stabilize dashboard settings smoke
This commit is contained in:
@@ -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 (
|
||||
<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 }) {
|
||||
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
|
||||
null,
|
||||
@@ -70,9 +199,8 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
|
||||
const [selectedAgentType, setSelectedAgentType] = useState<
|
||||
'claude-code' | 'codex'
|
||||
>('codex');
|
||||
const [selectedAgentType, setSelectedAgentType] =
|
||||
useState<AgentType>('codex');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -180,91 +308,27 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
|
||||
<div className="settings-form-grid settings-skill-controls">
|
||||
<label className="settings-row">
|
||||
<span className="settings-label">
|
||||
{t.settings.runtime.selectRoomLabel}
|
||||
</span>
|
||||
<select
|
||||
onChange={(event) => setSelectedRoomJid(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={() => setSelectedAgentType(agent.agentType)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{agentLabel(agent.agentType, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<RoomSkillControls
|
||||
onAgentChange={setSelectedAgentType}
|
||||
onRoomChange={setSelectedRoomJid}
|
||||
selectedAgentType={selectedAgentType}
|
||||
selectedRoom={selectedRoom}
|
||||
selectedRoomJid={selectedRoomJid}
|
||||
snapshot={snapshot}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
|
||||
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
|
||||
) : (
|
||||
<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) =>
|
||||
void handleToggle({
|
||||
roomJid: selectedRoomJid,
|
||||
agentType: selectedAgent.agentType,
|
||||
skillId,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
<SkillToggleList
|
||||
catalogById={catalogById}
|
||||
onToggle={(input) => void handleToggle(input)}
|
||||
savingKey={savingKey}
|
||||
selectedAgent={selectedAgent}
|
||||
selectedRoomJid={selectedRoomJid}
|
||||
t={t}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
<div className="settings-model-stack">
|
||||
{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 &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -24,7 +24,16 @@ async function main() {
|
||||
|
||||
try {
|
||||
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(
|
||||
'settings nav keeps hash route stable',
|
||||
browser,
|
||||
@@ -40,9 +49,7 @@ async function main() {
|
||||
);
|
||||
|
||||
await page
|
||||
.locator(
|
||||
'.settings-nav button[data-settings-target="settings-codex"]',
|
||||
)
|
||||
.locator('.settings-nav button[data-settings-target="settings-codex"]')
|
||||
.click();
|
||||
await page.waitForTimeout(150);
|
||||
assert.equal(page.url(), originalUrl);
|
||||
@@ -86,7 +93,7 @@ async function main() {
|
||||
);
|
||||
|
||||
await runScenario(
|
||||
'codex goal toggle persists and explains restart',
|
||||
'codex goal toggle persists and explains next-run apply',
|
||||
browser,
|
||||
baseUrl,
|
||||
async (page, state) => {
|
||||
@@ -99,7 +106,7 @@ async function main() {
|
||||
|
||||
await goalToggle.click();
|
||||
await page
|
||||
.getByText('저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.')
|
||||
.getByText('스택 재시작 없이 다음 Codex 작업부터 적용됩니다.')
|
||||
.waitFor();
|
||||
|
||||
assert.equal(await goalToggle.isChecked(), true);
|
||||
@@ -195,7 +202,9 @@ async function main() {
|
||||
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,
|
||||
@@ -262,12 +271,7 @@ async function main() {
|
||||
browser,
|
||||
baseUrl,
|
||||
async (page) => {
|
||||
for (const hash of [
|
||||
'#/rooms',
|
||||
'#/scheduled',
|
||||
'#/usage',
|
||||
'#/settings',
|
||||
]) {
|
||||
for (const hash of ['#/rooms', '#/scheduled', '#/usage', '#/settings']) {
|
||||
await page.goto(new URL(hash, baseUrl).toString(), {
|
||||
waitUntil: 'networkidle',
|
||||
});
|
||||
@@ -299,12 +303,6 @@ async function main() {
|
||||
await assertNoHorizontalOverflow(page);
|
||||
},
|
||||
);
|
||||
|
||||
console.log('dashboard:ux passed');
|
||||
} finally {
|
||||
await browser.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
|
||||
@@ -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,7 +539,43 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
}
|
||||
|
||||
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,
|
||||
referenceModels: ['kimi', 'glm'],
|
||||
models: [
|
||||
@@ -565,12 +598,11 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
lastStatus: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'GET' && url.pathname === '/api/settings/accounts') {
|
||||
await fulfillJson(route, {
|
||||
function mockAccounts() {
|
||||
return {
|
||||
claude: [
|
||||
{
|
||||
index: 0,
|
||||
@@ -606,27 +638,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
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,
|
||||
},
|
||||
liveStatus: mockCodexLiveStatus(),
|
||||
exists: true,
|
||||
},
|
||||
{
|
||||
@@ -649,13 +661,35 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
},
|
||||
],
|
||||
codexCurrentIndex: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'POST' && url.pathname === '/api/services/stack/actions') {
|
||||
state.restartRequests += 1;
|
||||
await fulfillJson(route, {
|
||||
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',
|
||||
@@ -665,15 +699,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
||||
status: 'running',
|
||||
services: ['ejclaw'],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(
|
||||
route,
|
||||
{ error: `Unhandled mock route ${method} ${url.pathname}` },
|
||||
404,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMockRoomSkillRoute(
|
||||
|
||||
@@ -233,18 +233,17 @@ async function triggerMessage(message: any) {
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
describe('DiscordChannel', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hasReviewerLeaseMock.mockReturnValue(false);
|
||||
loginShouldRejectRef.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel registration', () => {
|
||||
describe('channel registration', () => {
|
||||
it('warns when the canonical owner token is not configured', () => {
|
||||
const ownerFactory = registeredChannelFactories.get('discord');
|
||||
|
||||
@@ -254,11 +253,11 @@ describe('DiscordChannel', () => {
|
||||
'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 () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
@@ -307,11 +306,11 @@ describe('DiscordChannel', () => {
|
||||
|
||||
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 () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
@@ -507,11 +506,11 @@ describe('DiscordChannel', () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- bot mention handling ---
|
||||
// --- bot mention handling ---
|
||||
|
||||
describe('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);
|
||||
@@ -592,11 +591,11 @@ describe('DiscordChannel', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Attachments ---
|
||||
// --- Attachments ---
|
||||
|
||||
describe('attachments', () => {
|
||||
describe('attachments', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -718,9 +717,7 @@ describe('DiscordChannel', () => {
|
||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||
'dc:1234567890123456',
|
||||
expect.objectContaining({
|
||||
content: expect.stringMatching(
|
||||
/^Check this out\n\[Image: .+\.jpg\]$/,
|
||||
),
|
||||
content: expect.stringMatching(/^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 () => {
|
||||
const opts = createTestOpts();
|
||||
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 () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
@@ -880,15 +877,9 @@ describe('DiscordChannel', () => {
|
||||
};
|
||||
currentClient().channels.fetch.mockResolvedValue(mockChannel);
|
||||
|
||||
await channel.sendMessage(
|
||||
'dc:1234567890123456',
|
||||
'이미지를 생성했습니다.',
|
||||
{
|
||||
attachments: [
|
||||
{ path: filePath, name: 'result.png', mime: 'image/png' },
|
||||
],
|
||||
},
|
||||
);
|
||||
await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', {
|
||||
attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }],
|
||||
});
|
||||
|
||||
expect(mockChannel.send).toHaveBeenCalledWith({
|
||||
content: '이미지를 생성했습니다.',
|
||||
@@ -947,11 +938,11 @@ describe('DiscordChannel', () => {
|
||||
'Discord message sent',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- ownsJid ---
|
||||
// --- ownsJid ---
|
||||
|
||||
describe('ownsJid', () => {
|
||||
describe('ownsJid', () => {
|
||||
it('owns dc: JIDs', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
|
||||
@@ -983,11 +974,11 @@ describe('DiscordChannel', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.ownsJid('random-string')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- setTyping ---
|
||||
// --- setTyping ---
|
||||
|
||||
describe('setTyping', () => {
|
||||
describe('setTyping', () => {
|
||||
it('sends typing indicator when isTyping is true', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
@@ -1049,14 +1040,13 @@ describe('DiscordChannel', () => {
|
||||
|
||||
// No error
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Channel properties ---
|
||||
// --- Channel properties ---
|
||||
|
||||
describe('channel properties', () => {
|
||||
describe('channel properties', () => {
|
||||
it('has name "discord"', () => {
|
||||
const channel = new DiscordChannel('test-token', createTestOpts());
|
||||
expect(channel.name).toBe('discord');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user