fix: stabilize dashboard settings smoke

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

View File

@@ -10,7 +10,11 @@ import {
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
import { 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"
/>
</label>
);
})}
</div>
<SkillToggleList
catalogById={catalogById}
onToggle={(input) => void handleToggle(input)}
savingKey={savingKey}
selectedAgent={selectedAgent}
selectedRoomJid={selectedRoomJid}
t={t}
/>
)}
</SettingsCard>
)}

View File

@@ -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 &&

View File

@@ -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"

View File

@@ -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,

File diff suppressed because it is too large Load Diff