Skip to content

Commit 2c19d18

Browse files
IAmJSDclaude
andcommitted
Merge remote-tracking branch 'origin/main' into ui/issue-180
Conflicts: - frontend/src/dashboard.ts: - imports: kept this branch's filterByEnabledProviders import and all of main's new imports (upcoming-scope LeanerCloud#1365, sparse-trend helpers LeanerCloud#1334, chart-a11y LeanerCloud#1381, first-run empty state LeanerCloud#1389). - loadDashboard Promise.allSettled: combined both sides into five fetches; this branch's loadEnabledProviders() (LeanerCloud#180) plus main's listAccountsMinimal() for the first-run onboarding card (LeanerCloud#1389). - call site: kept renderDashboardSummary(summaryData, recs, recsCount) and main's renderHomeOnboarding(...). The onboarding check uses the Enabled-Providers-filtered recs, matching the Opportunities first-run check, which also runs on the preference-filtered list. - helpers: kept both loadEnabledProviders() and main's renderHomeOnboarding(); renderDashboardSummary keeps this branch's recsCount parameter. - savings tile detail: this branch's filtered count with main's NO_VALUE placeholder (LeanerCloud#1376): recsCount ?? total_recommendations ?? NO_VALUE; Active Commitments takes main's NO_VALUE. - frontend/src/recommendations.ts: import block; main's NO_VALUE added to the utils import, kept this branch's enabled-providers import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 0be9d78 + 049c617 commit 2c19d18

165 files changed

Lines changed: 8772 additions & 961 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎frontend/src/__tests__/allowed-accounts.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jest.mock('../navigation', () => ({
5252
}));
5353

5454
jest.mock('../utils', () => ({
55+
NO_VALUE: '\u2014',
5556
formatCurrency: jest.fn((val: number) => `$${val || 0}`),
5657
formatDate: jest.fn((val: string) => (val ? new Date(val).toLocaleDateString() : '')),
5758
formatTerm: jest.fn((years: number) => (years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`)),
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* Issue #748: user-facing wording for API and transport failures.
3+
*/
4+
import {
5+
BAD_RESPONSE_MESSAGE,
6+
NETWORK_ERROR_MESSAGE,
7+
friendlyError,
8+
httpStatusMessage,
9+
isGenericServerMessage,
10+
isNetworkFailure,
11+
readJson,
12+
responseError,
13+
} from '../api/errors';
14+
15+
function response(status: number, body: () => Promise<unknown>): Response {
16+
return { ok: status < 400, status, url: '/api/auth/login', json: body } as unknown as Response;
17+
}
18+
19+
describe('isNetworkFailure', () => {
20+
test.each(['Failed to fetch', 'NetworkError when attempting to fetch resource.', 'Load failed'])(
21+
'recognizes the browser fetch TypeError "%s"', (msg) => {
22+
expect(isNetworkFailure(new TypeError(msg))).toBe(true);
23+
});
24+
25+
test('ignores other errors', () => {
26+
expect(isNetworkFailure(new TypeError('x is not a function'))).toBe(false);
27+
expect(isNetworkFailure(new Error('Failed to fetch'))).toBe(false);
28+
});
29+
});
30+
31+
describe('friendlyError', () => {
32+
test('maps a raw fetch failure and a JSON parse error', () => {
33+
expect(friendlyError(new TypeError('Failed to fetch'))).toBe(NETWORK_ERROR_MESSAGE);
34+
expect(friendlyError(new SyntaxError("Unexpected token '<'"))).toBe(BAD_RESPONSE_MESSAGE);
35+
});
36+
37+
test('keeps an error message the server or client already made readable', () => {
38+
expect(friendlyError(Object.assign(new Error('plan name already in use'), { status: 409 }))).toBe('plan name already in use');
39+
});
40+
41+
test('falls back to the status text, then a generic sentence', () => {
42+
expect(friendlyError(Object.assign(new Error(''), { status: 503 }))).toBe(httpStatusMessage(503));
43+
expect(friendlyError(undefined)).toBe('Something went wrong. Try again in a moment.');
44+
expect(friendlyError('quota exceeded')).toBe('quota exceeded');
45+
});
46+
});
47+
48+
describe('isGenericServerMessage', () => {
49+
test('matches only the backend catch-all', () => {
50+
expect(isGenericServerMessage('Internal server error')).toBe(true);
51+
expect(isGenericServerMessage('internal server error.')).toBe(true);
52+
expect(isGenericServerMessage('internal server error: db down')).toBe(false);
53+
});
54+
});
55+
56+
describe('responseError', () => {
57+
test('prefers a specific server error, then message', async () => {
58+
await expect(responseError(response(400, () => Promise.resolve({ error: 'bad email' })), 'Login failed'))
59+
.resolves.toMatchObject({ message: 'bad email', status: 400 });
60+
await expect(responseError(response(400, () => Promise.resolve({ message: 'weak password' })), 'x'))
61+
.resolves.toMatchObject({ message: 'weak password' });
62+
});
63+
64+
test('uses the friendly status text for a 5xx with no or a generic body', async () => {
65+
await expect(responseError(response(502, () => Promise.reject(new SyntaxError('<'))), 'Login failed'))
66+
.resolves.toMatchObject({ message: httpStatusMessage(502), status: 502, detail: 'HTTP 502 from /api/auth/login' });
67+
await expect(responseError(response(500, () => Promise.resolve({ error: 'Internal server error' })), 'Login failed'))
68+
.resolves.toMatchObject({ message: httpStatusMessage(500) });
69+
});
70+
71+
test('uses the caller fallback for a 4xx with no message', async () => {
72+
await expect(responseError(response(401, () => Promise.resolve({})), 'Login failed'))
73+
.resolves.toMatchObject({ message: 'Login failed', status: 401 });
74+
});
75+
});
76+
77+
describe('readJson', () => {
78+
test('returns the parsed body', async () => {
79+
await expect(readJson(response(200, () => Promise.resolve({ a: 1 })))).resolves.toEqual({ a: 1 });
80+
});
81+
82+
test('turns a parse failure into friendly text', async () => {
83+
await expect(readJson(response(200, () => Promise.reject(new SyntaxError("Unexpected token '<'")))))
84+
.rejects.toMatchObject({ message: BAD_RESPONSE_MESSAGE, status: 200 });
85+
});
86+
});

‎frontend/src/__tests__/api-history.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ describe('API History Module', () => {
8585
);
8686
});
8787

88+
test('passes the paging cursor after the filters (issue #676)', async () => {
89+
(apiRequest as jest.Mock).mockResolvedValue({});
90+
91+
await getHistory({ provider: 'aws', cursor: 'djEuYS4x' });
92+
93+
expect(apiRequest).toHaveBeenCalledWith('/history?provider=aws&cursor=djEuYS4x');
94+
});
95+
8896
test('handles empty filters object', async () => {
8997
(apiRequest as jest.Mock).mockResolvedValue([]);
9098

‎frontend/src/__tests__/api.test.ts‎

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
getDeploymentInfo
3838
} from '../api';
3939
import { base64Encode } from '../api/client';
40-
import type { CreatePlanRequest, Config, Recommendation } from '../api';
40+
import type { CreatePlanRequest, Config, Recommendation, UpcomingPurchasesResponse } from '../api';
4141

4242
describe('Authentication', () => {
4343
beforeEach(() => {
@@ -404,7 +404,12 @@ describe('API Requests', () => {
404404
status: 200,
405405
json: () => Promise.reject(new SyntaxError('Unexpected token in JSON at position 0')),
406406
});
407-
await expect(apiRequest('/test')).rejects.toThrow(SyntaxError);
407+
// Issue #748: rejects with friendly text, not "Unexpected token ...".
408+
await expect(apiRequest('/test')).rejects.toMatchObject({
409+
message: 'CUDly sent an unexpected response. Try again in a moment.',
410+
status: 200,
411+
detail: expect.stringContaining('Unexpected token in JSON'),
412+
});
408413
});
409414

410415
test('surfaces a timeout error when the backend hangs past timeoutMs (issue #20)', async () => {
@@ -419,7 +424,56 @@ describe('API Requests', () => {
419424
});
420425
}));
421426

422-
await expect(apiRequest('/test', { timeoutMs: 10 })).rejects.toThrow(/timed out after 10ms/);
427+
// Issue #748: the user sees a sentence; the raw timing stays on detail.
428+
await expect(apiRequest('/test', { timeoutMs: 10 })).rejects.toMatchObject({
429+
message: 'CUDly took too long to respond. Try again in a moment.',
430+
status: 0,
431+
detail: 'Request to /test timed out after 10ms',
432+
});
433+
});
434+
435+
describe('friendly transport errors (issue #748)', () => {
436+
test('a network failure becomes "Can\'t reach CUDly" with status 0', async () => {
437+
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
438+
await expect(apiRequest('/test')).rejects.toMatchObject({
439+
message: "Can't reach CUDly. Check your connection and try again.",
440+
status: 0,
441+
detail: 'Request to /test failed: Failed to fetch',
442+
});
443+
});
444+
445+
test('a caller abort is rethrown untouched', async () => {
446+
const controller = new AbortController();
447+
fetchMock.mockImplementation(() => {
448+
controller.abort();
449+
const err = new Error('The operation was aborted.');
450+
err.name = 'AbortError';
451+
return Promise.reject(err);
452+
});
453+
await expect(apiRequest('/test', { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' });
454+
});
455+
456+
test.each([
457+
[502, 'CUDly is temporarily unavailable. Try again in a moment.'],
458+
[503, 'CUDly is temporarily unavailable. Try again in a moment.'],
459+
[504, 'CUDly took too long to respond. Try again in a moment.'],
460+
[500, 'CUDly hit an unexpected error. Try again in a moment.'],
461+
[403, "You don't have permission to do that."],
462+
[418, 'The request could not be completed (error 418).'],
463+
])('HTTP %i with an HTML body says what it means, not "HTTP %i"', async (status, message) => {
464+
fetchMock.mockResolvedValue({ ok: false, status, json: () => Promise.reject(new SyntaxError("Unexpected token '<'")) });
465+
await expect(apiRequest('/test')).rejects.toMatchObject({ message, status, detail: `HTTP ${status} from /test` });
466+
});
467+
468+
test('the backend catch-all "Internal server error" gets the friendly 500 text', async () => {
469+
fetchMock.mockResolvedValue({ ok: false, status: 500, json: () => Promise.resolve({ error: 'Internal server error' }) });
470+
await expect(apiRequest('/test')).rejects.toThrow('CUDly hit an unexpected error. Try again in a moment.');
471+
});
472+
473+
test('a specific server message still wins over the status text', async () => {
474+
fetchMock.mockResolvedValue({ ok: false, status: 503, json: () => Promise.resolve({ error: 'maintenance until 10:00 UTC' }) });
475+
await expect(apiRequest('/test')).rejects.toThrow('maintenance until 10:00 UTC');
476+
});
423477
});
424478

425479
test('passes the caller-provided signal through alongside the timeout controller', async () => {
@@ -662,15 +716,23 @@ describe('API Requests', () => {
662716
expect(localStorageMock.setItem).toHaveBeenCalledWith('authToken', 'admin-token');
663717
});
664718

665-
test('a non-JSON error body (proxy 502) yields a readable message, not a SyntaxError', async () => {
666-
fetchMock.mockResolvedValue({
667-
ok: false,
668-
status: 502,
669-
json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON at position 0')),
670-
});
719+
// Issue #748: a proxy 502 with an HTML body used to surface as
720+
// "Unexpected token '<'" because the error body was parsed uncaught.
721+
test('a 502 HTML error page fails with friendly text, not a JSON parse error', async () => {
722+
fetchMock.mockResolvedValue({ ok: false, status: 502, json: () => Promise.reject(new SyntaxError("Unexpected token '<'")) });
723+
await expect(setupAdmin('api-key', 'admin@example.com', 'password'))
724+
.rejects.toMatchObject({ message: 'CUDly is temporarily unavailable. Try again in a moment.', status: 502 });
725+
});
726+
727+
test('a 400 keeps the server reason', async () => {
728+
fetchMock.mockResolvedValue({ ok: false, status: 400, json: () => Promise.resolve({ error: 'admin already exists' }) });
729+
await expect(setupAdmin('api-key', 'admin@example.com', 'password')).rejects.toThrow('admin already exists');
730+
});
671731

732+
test('a network failure says the server cannot be reached', async () => {
733+
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
672734
await expect(setupAdmin('api-key', 'admin@example.com', 'password'))
673-
.rejects.toThrow('Failed to create admin (HTTP 502)');
735+
.rejects.toThrow("Can't reach CUDly. Check your connection and try again.");
674736
});
675737

676738
test('a JSON error body still surfaces the server message', async () => {
@@ -740,6 +802,21 @@ describe('Dashboard API', () => {
740802
expect.anything()
741803
);
742804
});
805+
806+
// #1189: the endpoint returns { purchases: [...] }, not a bare array. The
807+
// annotation makes tsc (npm run typecheck) reject a regression to the
808+
// old UpcomingPurchase[] return type.
809+
test('resolves the { purchases } envelope the handler returns', async () => {
810+
const row = {
811+
execution_id: 'exec-1', plan_id: 'plan-1', plan_name: 'Plan', scheduled_date: '2026-11-01',
812+
provider: 'aws', service: 'ec2', step_number: 1, total_steps: 2, estimated_savings: 10,
813+
has_recommendations: true,
814+
};
815+
fetchMock.mockResolvedValueOnce({ ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve({ purchases: [row] }) });
816+
const res: UpcomingPurchasesResponse = await getUpcomingPurchases();
817+
expect(Array.isArray(res)).toBe(false);
818+
expect(res.purchases).toEqual([row]);
819+
});
743820
});
744821
});
745822

‎frontend/src/__tests__/apikeys.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -962,7 +962,7 @@ describe('API Keys Module', () => {
962962

963963
await revokeApiKey('key-1');
964964

965-
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to revoke API key');
965+
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to revoke API key: Revoke failed');
966966
expect(document.getElementById('create-apikey-error')?.classList.contains('hidden')).toBe(true);
967967
consoleError.mockRestore();
968968
});
@@ -977,7 +977,7 @@ describe('API Keys Module', () => {
977977
await revokeApiKey('key-1');
978978

979979
expect(consoleError).toHaveBeenCalledWith('Failed to revoke API key:', expect.any(Error));
980-
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to revoke API key');
980+
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to revoke API key: Revoke failed');
981981
consoleError.mockRestore();
982982
});
983983
});
@@ -1031,7 +1031,7 @@ describe('API Keys Module', () => {
10311031
await deleteApiKey('key-1');
10321032

10331033
expect(consoleError).toHaveBeenCalledWith('Failed to delete API key:', expect.any(Error));
1034-
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to delete API key');
1034+
expect(document.querySelector('.toast-error')?.querySelector('.toast-message')?.textContent).toBe('Failed to delete API key: Delete failed');
10351035
consoleError.mockRestore();
10361036
});
10371037
});

‎frontend/src/__tests__/approval-queue-column-filters.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { ApprovalQueueColumnFilters } from '../state';
2424
jest.mock('../api', () => ({}));
2525
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
2626
jest.mock('../utils', () => ({
27+
NO_VALUE: '\u2014',
2728
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
2829
formatDate: jest.fn((v) => v),
2930
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),

0 commit comments

Comments
 (0)