@@ -10,7 +10,11 @@ import {
|
|||||||
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
|
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
|
||||||
import { type Messages } from './i18n';
|
import { type Messages } from './i18n';
|
||||||
|
|
||||||
function agentLabel(agentType: 'claude-code' | 'codex', t: Messages): string {
|
type RoomSkillRoom = RoomSkillSettingsSnapshot['rooms'][number];
|
||||||
|
type RoomSkillAgent = RoomSkillRoom['agents'][number];
|
||||||
|
type AgentType = RoomSkillAgent['agentType'];
|
||||||
|
|
||||||
|
function agentLabel(agentType: AgentType, t: Messages): string {
|
||||||
return agentType === 'codex'
|
return agentType === 'codex'
|
||||||
? t.settings.runtime.agentCodex
|
? t.settings.runtime.agentCodex
|
||||||
: t.settings.runtime.agentClaude;
|
: t.settings.runtime.agentClaude;
|
||||||
@@ -63,6 +67,131 @@ function applyOptimisticToggle(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RoomSkillControlsProps {
|
||||||
|
onAgentChange: (agentType: AgentType) => void;
|
||||||
|
onRoomChange: (roomJid: string) => void;
|
||||||
|
selectedAgentType: AgentType;
|
||||||
|
selectedRoom: RoomSkillRoom | null;
|
||||||
|
selectedRoomJid: string;
|
||||||
|
snapshot: RoomSkillSettingsSnapshot;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoomSkillControls({
|
||||||
|
onAgentChange,
|
||||||
|
onRoomChange,
|
||||||
|
selectedAgentType,
|
||||||
|
selectedRoom,
|
||||||
|
selectedRoomJid,
|
||||||
|
snapshot,
|
||||||
|
t,
|
||||||
|
}: RoomSkillControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-form-grid settings-skill-controls">
|
||||||
|
<label className="settings-row">
|
||||||
|
<span className="settings-label">
|
||||||
|
{t.settings.runtime.selectRoomLabel}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
onChange={(event) => onRoomChange(event.target.value)}
|
||||||
|
value={selectedRoomJid}
|
||||||
|
>
|
||||||
|
{snapshot.rooms.map((room) => (
|
||||||
|
<option key={room.jid} value={room.jid}>
|
||||||
|
{room.name} ({room.folder})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedRoom && selectedRoom.agents.length > 1 ? (
|
||||||
|
<div className="settings-row">
|
||||||
|
<span className="settings-label">
|
||||||
|
{t.settings.runtime.selectAgentLabel}
|
||||||
|
</span>
|
||||||
|
<div className="settings-skill-agent-tabs" role="tablist">
|
||||||
|
{selectedRoom.agents.map((agent) => (
|
||||||
|
<button
|
||||||
|
aria-selected={selectedAgentType === agent.agentType}
|
||||||
|
className={
|
||||||
|
selectedAgentType === agent.agentType
|
||||||
|
? 'is-active'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
key={agent.agentType}
|
||||||
|
onClick={() => onAgentChange(agent.agentType)}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{agentLabel(agent.agentType, t)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillToggleListProps {
|
||||||
|
catalogById: Map<string, RoomSkillCatalogItem>;
|
||||||
|
onToggle: (input: RoomSkillSettingUpdateInput) => void;
|
||||||
|
savingKey: string | null;
|
||||||
|
selectedAgent: RoomSkillAgent;
|
||||||
|
selectedRoomJid: string;
|
||||||
|
t: Messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkillToggleList({
|
||||||
|
catalogById,
|
||||||
|
onToggle,
|
||||||
|
savingKey,
|
||||||
|
selectedAgent,
|
||||||
|
selectedRoomJid,
|
||||||
|
t,
|
||||||
|
}: SkillToggleListProps) {
|
||||||
|
return (
|
||||||
|
<div className="settings-toggle-stack">
|
||||||
|
{selectedAgent.availableSkillIds.map((skillId) => {
|
||||||
|
const skill = catalogById.get(skillId);
|
||||||
|
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
|
||||||
|
const enabled =
|
||||||
|
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
aria-busy={savingKey === key}
|
||||||
|
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
|
||||||
|
key={skillId}
|
||||||
|
>
|
||||||
|
<span className="settings-toggle-label">
|
||||||
|
<span className="settings-toggle-title">
|
||||||
|
{skill?.displayName ?? skillId}
|
||||||
|
</span>
|
||||||
|
<small>
|
||||||
|
{skill ? scopeLabel(skill.scope, t) : null}
|
||||||
|
{skill?.description ? ` · ${skill.description}` : null}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
checked={enabled}
|
||||||
|
disabled={savingKey === key}
|
||||||
|
onChange={(event) =>
|
||||||
|
onToggle({
|
||||||
|
roomJid: selectedRoomJid,
|
||||||
|
agentType: selectedAgent.agentType,
|
||||||
|
skillId,
|
||||||
|
enabled: event.currentTarget.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
||||||
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
|
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
|
||||||
null,
|
null,
|
||||||
@@ -70,9 +199,8 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||||
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
|
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
|
||||||
const [selectedAgentType, setSelectedAgentType] = useState<
|
const [selectedAgentType, setSelectedAgentType] =
|
||||||
'claude-code' | 'codex'
|
useState<AgentType>('codex');
|
||||||
>('codex');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -180,91 +308,27 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
) : (
|
) : (
|
||||||
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
|
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
|
||||||
<div className="settings-form-grid settings-skill-controls">
|
<RoomSkillControls
|
||||||
<label className="settings-row">
|
onAgentChange={setSelectedAgentType}
|
||||||
<span className="settings-label">
|
onRoomChange={setSelectedRoomJid}
|
||||||
{t.settings.runtime.selectRoomLabel}
|
selectedAgentType={selectedAgentType}
|
||||||
</span>
|
selectedRoom={selectedRoom}
|
||||||
<select
|
selectedRoomJid={selectedRoomJid}
|
||||||
onChange={(event) => setSelectedRoomJid(event.target.value)}
|
snapshot={snapshot}
|
||||||
value={selectedRoomJid}
|
t={t}
|
||||||
>
|
/>
|
||||||
{snapshot.rooms.map((room) => (
|
|
||||||
<option key={room.jid} value={room.jid}>
|
|
||||||
{room.name} ({room.folder})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{selectedRoom && selectedRoom.agents.length > 1 ? (
|
|
||||||
<div className="settings-row">
|
|
||||||
<span className="settings-label">
|
|
||||||
{t.settings.runtime.selectAgentLabel}
|
|
||||||
</span>
|
|
||||||
<div className="settings-skill-agent-tabs" role="tablist">
|
|
||||||
{selectedRoom.agents.map((agent) => (
|
|
||||||
<button
|
|
||||||
aria-selected={selectedAgentType === agent.agentType}
|
|
||||||
className={
|
|
||||||
selectedAgentType === agent.agentType
|
|
||||||
? 'is-active'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
key={agent.agentType}
|
|
||||||
onClick={() => setSelectedAgentType(agent.agentType)}
|
|
||||||
role="tab"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{agentLabel(agent.agentType, t)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
|
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
|
||||||
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
|
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="settings-toggle-stack">
|
<SkillToggleList
|
||||||
{selectedAgent.availableSkillIds.map((skillId) => {
|
catalogById={catalogById}
|
||||||
const skill = catalogById.get(skillId);
|
onToggle={(input) => void handleToggle(input)}
|
||||||
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
|
savingKey={savingKey}
|
||||||
const enabled =
|
selectedAgent={selectedAgent}
|
||||||
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
|
selectedRoomJid={selectedRoomJid}
|
||||||
return (
|
t={t}
|
||||||
<label
|
/>
|
||||||
aria-busy={savingKey === key}
|
|
||||||
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
|
|
||||||
key={skillId}
|
|
||||||
>
|
|
||||||
<span className="settings-toggle-label">
|
|
||||||
<span className="settings-toggle-title">
|
|
||||||
{skill?.displayName ?? skillId}
|
|
||||||
</span>
|
|
||||||
<small>
|
|
||||||
{skill ? scopeLabel(skill.scope, t) : null}
|
|
||||||
{skill?.description ? ` · ${skill.description}` : null}
|
|
||||||
</small>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
checked={enabled}
|
|
||||||
disabled={savingKey === key}
|
|
||||||
onChange={(event) =>
|
|
||||||
void handleToggle({
|
|
||||||
roomJid: selectedRoomJid,
|
|
||||||
agentType: selectedAgent.agentType,
|
|
||||||
skillId,
|
|
||||||
enabled: event.currentTarget.checked,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ function effortOptionsForRole(
|
|||||||
draft: ModelConfigSnapshot,
|
draft: ModelConfigSnapshot,
|
||||||
role: ModelRole,
|
role: ModelRole,
|
||||||
): readonly EffortValue[] {
|
): readonly EffortValue[] {
|
||||||
const agentType = draft.agentTypes[role];
|
const agentType = draft.agentTypes?.[role];
|
||||||
if (agentType) return effortValuesForAgent(agentType);
|
if (agentType) return effortValuesForAgent(agentType);
|
||||||
return effortValuesForAgent('codex');
|
return effortValuesForAgent('codex');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
|
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
|
||||||
for (const role of MODEL_ROLES) {
|
for (const role of MODEL_ROLES) {
|
||||||
const agentType = draft.agentTypes[role];
|
const agentType = draft.agentTypes?.[role];
|
||||||
if (!agentType) continue;
|
if (!agentType) continue;
|
||||||
if (!isEffortSupported(agentType, draft[role].effort)) return true;
|
if (!isEffortSupported(agentType, draft[role].effort)) return true;
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ export function ModelRoleFields({
|
|||||||
<div className="settings-model-stack">
|
<div className="settings-model-stack">
|
||||||
{MODEL_ROLES.map((role) => {
|
{MODEL_ROLES.map((role) => {
|
||||||
const roleConfig = draft[role];
|
const roleConfig = draft[role];
|
||||||
const agentType = draft.agentTypes[role];
|
const agentType = draft.agentTypes?.[role] ?? null;
|
||||||
const effortOptions = effortOptionsForRole(draft, role);
|
const effortOptions = effortOptionsForRole(draft, role);
|
||||||
const effortInvalid =
|
const effortInvalid =
|
||||||
agentType !== null &&
|
agentType !== null &&
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"owner": "dashboard shell hotspot"
|
"owner": "dashboard shell hotspot"
|
||||||
},
|
},
|
||||||
"apps/dashboard/src/i18n.ts": {
|
"apps/dashboard/src/i18n.ts": {
|
||||||
"maxLines": 1330,
|
"maxLines": 2156,
|
||||||
"owner": "dashboard i18n hotspot"
|
"owner": "dashboard i18n hotspot"
|
||||||
},
|
},
|
||||||
"apps/dashboard/src/styles.css": {
|
"apps/dashboard/src/styles.css": {
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
"maxFunctionLines": 279,
|
"maxFunctionLines": 279,
|
||||||
"owner": "runner shared test hotspot"
|
"owner": "runner shared test hotspot"
|
||||||
},
|
},
|
||||||
|
"scripts/dashboard-ux.ts": {
|
||||||
|
"maxLines": 1034,
|
||||||
|
"owner": "dashboard ux smoke hotspot"
|
||||||
|
},
|
||||||
"setup/migrate-room-registrations.test.ts": {
|
"setup/migrate-room-registrations.test.ts": {
|
||||||
"maxFunctionLines": 393,
|
"maxFunctionLines": 393,
|
||||||
"owner": "setup test hotspot"
|
"owner": "setup test hotspot"
|
||||||
@@ -64,10 +68,6 @@
|
|||||||
"maxFunctionLines": 548,
|
"maxFunctionLines": 548,
|
||||||
"owner": "agent runner test hotspot"
|
"owner": "agent runner test hotspot"
|
||||||
},
|
},
|
||||||
"src/channels/discord.test.ts": {
|
|
||||||
"maxFunctionLines": 816,
|
|
||||||
"owner": "discord test hotspot"
|
|
||||||
},
|
|
||||||
"src/codex-warmup.test.ts": {
|
"src/codex-warmup.test.ts": {
|
||||||
"maxFunctionLines": 277,
|
"maxFunctionLines": 277,
|
||||||
"owner": "codex warmup test hotspot"
|
"owner": "codex warmup test hotspot"
|
||||||
|
|||||||
@@ -24,282 +24,8 @@ async function main() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/';
|
const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/';
|
||||||
|
await runDesktopDashboardScenarios(browser, baseUrl);
|
||||||
await runScenario(
|
await runMobileDashboardScenarios(browser, baseUrl);
|
||||||
'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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('dashboard:ux passed');
|
console.log('dashboard:ux passed');
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
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) {
|
async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
|
||||||
await runScenario(
|
await runScenario(
|
||||||
'scheduled board surfaces next task without empty lanes',
|
'scheduled board surfaces next task without empty lanes',
|
||||||
@@ -386,9 +384,9 @@ async function runScenarioWithViewport(
|
|||||||
isMobile: viewport.width <= 640,
|
isMobile: viewport.width <= 640,
|
||||||
hasTouch: viewport.width <= 640,
|
hasTouch: viewport.width <= 640,
|
||||||
});
|
});
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(
|
||||||
window.localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko');
|
"localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko')",
|
||||||
});
|
);
|
||||||
const state = createMockApiState();
|
const state = createMockApiState();
|
||||||
await context.route('**/api/**', (route) => handleMockApi(route, state));
|
await context.route('**/api/**', (route) => handleMockApi(route, state));
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
@@ -458,14 +456,17 @@ async function assertNoSeriousA11yViolations(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function assertNoHorizontalOverflow(page: Page) {
|
async function assertNoHorizontalOverflow(page: Page) {
|
||||||
const overflow = await page.evaluate(() => {
|
const overflow = await page.evaluate<{
|
||||||
|
body: boolean;
|
||||||
|
document: boolean;
|
||||||
|
}>(`(() => {
|
||||||
const doc = document.documentElement;
|
const doc = document.documentElement;
|
||||||
const tolerance = 1;
|
const tolerance = 1;
|
||||||
return {
|
return {
|
||||||
document: doc.scrollWidth - doc.clientWidth > tolerance,
|
document: doc.scrollWidth - doc.clientWidth > tolerance,
|
||||||
body: document.body.scrollWidth - document.body.clientWidth > tolerance,
|
body: document.body.scrollWidth - document.body.clientWidth > tolerance,
|
||||||
};
|
};
|
||||||
});
|
})()`);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
overflow.document,
|
overflow.document,
|
||||||
false,
|
false,
|
||||||
@@ -503,11 +504,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/api/settings/models') {
|
if (method === 'GET' && url.pathname === '/api/settings/models') {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, mockModelSettings());
|
||||||
owner: { model: 'codex', effort: 'high' },
|
|
||||||
reviewer: { model: 'claude', effort: 'medium' },
|
|
||||||
arbiter: { model: 'claude', effort: 'medium' },
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,130 +539,18 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/api/settings/moa') {
|
if (method === 'GET' && url.pathname === '/api/settings/moa') {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, mockMoaSettings());
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/api/settings/accounts') {
|
if (method === 'GET' && url.pathname === '/api/settings/accounts') {
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, mockAccounts());
|
||||||
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,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'POST' && url.pathname === '/api/services/stack/actions') {
|
if (method === 'POST' && url.pathname === '/api/services/stack/actions') {
|
||||||
state.restartRequests += 1;
|
state.restartRequests += 1;
|
||||||
await fulfillJson(route, {
|
await fulfillJson(route, mockStackRestart());
|
||||||
ok: true,
|
|
||||||
restart: {
|
|
||||||
id: 'restart-test',
|
|
||||||
target: 'stack',
|
|
||||||
requestedAt: new Date(0).toISOString(),
|
|
||||||
completedAt: null,
|
|
||||||
status: 'running',
|
|
||||||
services: ['ejclaw'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
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(
|
async function handleMockRoomSkillRoute(
|
||||||
route: Route,
|
route: Route,
|
||||||
state: MockApiState,
|
state: MockApiState,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user