fix(web): harden typed SPA chat lifecycle
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
b2e005f2b4
commit
caebf9ef70
@@ -233,7 +233,7 @@ describe('ChatPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows visible alert surfaces for a server error and a failed command result', async () => {
|
||||
it('shows visible alert surfaces for a server error and a stable failure copy for a failed command result', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('error', { conversationId: 'c1', error: 'The model is unavailable' });
|
||||
@@ -241,14 +241,116 @@ describe('ChatPage', () => {
|
||||
conversationId: 'c1',
|
||||
command: 'model',
|
||||
success: false,
|
||||
message: 'unknown model id',
|
||||
message: 'raw internal detail: stack trace at line 42',
|
||||
});
|
||||
});
|
||||
|
||||
const alerts = [...container.querySelectorAll('[role="alert"]')];
|
||||
const alertText = alerts.map((node) => node.textContent).join(' ');
|
||||
expect(alertText).toContain('The model is unavailable');
|
||||
expect(alertText).toContain('unknown model id');
|
||||
// Sanitized client copy, not the raw server-provided detail.
|
||||
expect(alertText).toContain('Command failed.');
|
||||
expect(alertText).not.toContain('raw internal detail');
|
||||
});
|
||||
|
||||
it('renders a safe fallback when session:info arrives with a malformed (non-array) availableThinkingLevels, without throwing', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('session:info', {
|
||||
conversationId: 'c1',
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude',
|
||||
thinkingLevel: 'medium',
|
||||
availableThinkingLevels: null,
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.querySelector('section[aria-label="Session info"]')).toBeTruthy();
|
||||
const select = container.querySelector(
|
||||
'select[aria-label="Thinking level"]',
|
||||
) as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
// A malformed level list still shows a visible, safe placeholder option
|
||||
// rather than a silently empty select.
|
||||
expect([...select.options]).toHaveLength(1);
|
||||
expect(select.options[0]?.textContent).toMatch(/unavailable/i);
|
||||
|
||||
await act(async () => {
|
||||
selectValue(select, '');
|
||||
});
|
||||
expect(fake.emitted.filter((e) => e.event === 'set:thinking')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders honest unavailable labels — not fabricated zeros — when agent:end usage has malformed/missing numeric fields', async () => {
|
||||
await act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmit('agent:start', { conversationId: 'c1' });
|
||||
fake.serverEmitRaw('agent:end', {
|
||||
conversationId: 'c1',
|
||||
usage: {
|
||||
provider: { nested: 'object' },
|
||||
modelId: undefined,
|
||||
thinkingLevel: 'medium',
|
||||
tokens: { total: 'not-a-number' },
|
||||
cost: undefined,
|
||||
context: { percent: null, window: 200000 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const usage = container.querySelector('[aria-label="Usage"]');
|
||||
expect(usage).toBeTruthy();
|
||||
expect(usage?.textContent).toContain('tokens unavailable');
|
||||
expect(usage?.textContent).toContain('cost unavailable');
|
||||
expect(usage?.textContent).not.toContain('0 tokens');
|
||||
expect(usage?.textContent).not.toContain('$0.0000');
|
||||
expect(usage?.textContent).toContain('unknown/unknown');
|
||||
});
|
||||
|
||||
it('renders a safe fallback for message:ack when messageId is a malformed non-string value, without throwing', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('message:ack', { conversationId: 'c1', messageId: { bad: 'object' } });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = [...container.querySelectorAll('[role="status"]')].find((node) =>
|
||||
node.textContent?.includes('Message accepted'),
|
||||
);
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed messageId gets a stable, visible fallback — never blank,
|
||||
// never the raw object.
|
||||
expect(status?.textContent).toContain('unknown');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when system:reload.message is a malformed non-string value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmitRaw('system:reload', {
|
||||
commands: [],
|
||||
skills: [],
|
||||
providers: [],
|
||||
message: { bad: 'object' },
|
||||
});
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).toBeTruthy();
|
||||
// A malformed reload message renders a stable, visible fallback rather
|
||||
// than a silently empty status line.
|
||||
expect(status?.textContent).toContain('Commands reloaded.');
|
||||
});
|
||||
|
||||
it('renders safely and does not throw when a scoped error carries a malformed non-string error value', async () => {
|
||||
await expect(
|
||||
act(async () => {
|
||||
fake.serverEmit('message:ack', { conversationId: 'c1', messageId: 'm1' });
|
||||
fake.serverEmitRaw('error', { conversationId: 'c1', error: ['not', 'a', 'string'] });
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sends a message with optional provider/model fields and emits abort from the Stop control', async () => {
|
||||
|
||||
Reference in New Issue
Block a user