Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
21
frontend/tests/antigravityQuotaCountdown.test.ts
Normal file
21
frontend/tests/antigravityQuotaCountdown.test.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { getNextAntigravityCountdownUpdateDelay } from '@/features/quota/providers/antigravity/countdown';
|
||||
|
||||
const MINUTE_MS = 60_000;
|
||||
|
||||
describe('Antigravity quota countdown scheduling', () => {
|
||||
test('updates at the next rounded-minute boundary', () => {
|
||||
expect(getNextAntigravityCountdownUpdateDelay([10.5 * MINUTE_MS], 0)).toBe(30_000);
|
||||
expect(getNextAntigravityCountdownUpdateDelay([10 * MINUTE_MS], 0)).toBe(MINUTE_MS);
|
||||
});
|
||||
|
||||
test('uses the earliest boundary across quota buckets', () => {
|
||||
expect(getNextAntigravityCountdownUpdateDelay([10.75 * MINUTE_MS, 3.25 * MINUTE_MS], 0)).toBe(
|
||||
15_000
|
||||
);
|
||||
});
|
||||
|
||||
test('stops scheduling after all reset times expire', () => {
|
||||
expect(getNextAntigravityCountdownUpdateDelay([0, Number.NaN], MINUTE_MS)).toBeNull();
|
||||
});
|
||||
});
|
||||
51
frontend/tests/apiError.test.ts
Normal file
51
frontend/tests/apiError.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { parseApiErrorResponse } from '../src/services/api/apiError';
|
||||
|
||||
describe('Management API error parsing', () => {
|
||||
test('prefers the human-readable message and preserves the API error code', () => {
|
||||
const result = parseApiErrorResponse(
|
||||
{
|
||||
error: 'plugin_install_failed',
|
||||
message: 'download plugin archive: 404 Not Found',
|
||||
},
|
||||
'Request failed with status code 502'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
message: 'download plugin archive: 404 Not Found',
|
||||
apiCode: 'plugin_install_failed',
|
||||
});
|
||||
});
|
||||
|
||||
test('falls back to a string error used by legacy endpoints', () => {
|
||||
expect(parseApiErrorResponse({ error: 'invalid body' }, 'Bad Request')).toEqual({
|
||||
message: 'invalid body',
|
||||
apiCode: 'invalid body',
|
||||
});
|
||||
});
|
||||
|
||||
test('supports nested error messages and codes', () => {
|
||||
expect(
|
||||
parseApiErrorResponse(
|
||||
{ error: { code: 'invalid_config', message: 'plugins-dir is invalid' } },
|
||||
'Bad Request'
|
||||
)
|
||||
).toEqual({
|
||||
message: 'plugins-dir is invalid',
|
||||
apiCode: 'invalid_config',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses a text response body before the transport fallback', () => {
|
||||
expect(parseApiErrorResponse('upstream unavailable', 'Network Error')).toEqual({
|
||||
message: 'upstream unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the transport message for an unknown response shape', () => {
|
||||
expect(parseApiErrorResponse({ error: null }, 'Network Error')).toEqual({
|
||||
message: 'Network Error',
|
||||
apiCode: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
17
frontend/tests/apiKey.test.ts
Normal file
17
frontend/tests/apiKey.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { generateSecureApiKey } from '../src/utils/apiKey';
|
||||
|
||||
describe('API key generation', () => {
|
||||
test('generates a 51-character key with the expected prefix and charset', () => {
|
||||
const apiKey = generateSecureApiKey();
|
||||
|
||||
expect(apiKey).toHaveLength(51);
|
||||
expect(apiKey).toMatch(/^sk-[A-Za-z0-9]{48}$/);
|
||||
});
|
||||
|
||||
test('generates distinct keys', () => {
|
||||
const apiKeys = Array.from({ length: 100 }, () => generateSecureApiKey());
|
||||
|
||||
expect(new Set(apiKeys).size).toBe(apiKeys.length);
|
||||
});
|
||||
});
|
||||
82
frontend/tests/apiKeyStrength.test.ts
Normal file
82
frontend/tests/apiKeyStrength.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { generateSecureApiKey } from '../src/utils/apiKey';
|
||||
import {
|
||||
API_KEY_STRENGTH_SEGMENTS,
|
||||
evaluateApiKeyStrength,
|
||||
type ApiKeyStrengthTier,
|
||||
} from '../src/utils/apiKeyStrength';
|
||||
|
||||
const TIER_ORDER: ApiKeyStrengthTier[] = ['weak', 'fair', 'good', 'strong'];
|
||||
|
||||
describe('API key strength', () => {
|
||||
test('empty input lights no segment', () => {
|
||||
for (const value of ['', ' ']) {
|
||||
expect(evaluateApiKeyStrength(value)).toEqual({ tier: 'weak', segments: 0, bits: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
test('segments track the tier index', () => {
|
||||
const samples = ['a', 'Tr0ub4dor', 'Xk7#mQ2vLp9$Rn4wZt6c', generateSecureApiKey()];
|
||||
|
||||
for (const sample of samples) {
|
||||
const { tier, segments } = evaluateApiKeyStrength(sample);
|
||||
expect(segments).toBe(TIER_ORDER.indexOf(tier) + 1);
|
||||
expect(segments).toBeLessThanOrEqual(API_KEY_STRENGTH_SEGMENTS);
|
||||
}
|
||||
});
|
||||
|
||||
test('generated keys always reach the top tier', () => {
|
||||
for (let i = 0; i < 50; i += 1) {
|
||||
const { tier, segments } = evaluateApiKeyStrength(generateSecureApiKey());
|
||||
expect(tier).toBe('strong');
|
||||
expect(segments).toBe(API_KEY_STRENGTH_SEGMENTS);
|
||||
}
|
||||
});
|
||||
|
||||
test('short keys are capped regardless of charset richness', () => {
|
||||
// 7 位就算四类字符齐全也只能是最弱档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2').tier).toBe('weak');
|
||||
// 15 位封顶在第二档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4').tier).toBe('fair');
|
||||
// 23 位封顶在第三档
|
||||
expect(evaluateApiKeyStrength('aA1!bB2@cC3#dD4$eE5%fG').tier).toBe('good');
|
||||
});
|
||||
|
||||
test('repeated and sequential runs are discounted', () => {
|
||||
const repeated = evaluateApiKeyStrength('a'.repeat(48));
|
||||
expect(repeated.tier).toBe('weak');
|
||||
|
||||
const sequential = evaluateApiKeyStrength('abcdefghijklmnopqrstuvwxyz0123456789');
|
||||
const shuffled = evaluateApiKeyStrength('qzmXe4Rk9BtLw7Ncy2VsJp5Ghd8FaU3Zmr6Q');
|
||||
expect(sequential.bits).toBeLessThan(shuffled.bits);
|
||||
});
|
||||
|
||||
test('periodic keys score like a single period', () => {
|
||||
// 32 位却只有 8 位的猜测成本
|
||||
expect(evaluateApiKeyStrength('deadbeefdeadbeefdeadbeefdeadbeef').tier).toBe('fair');
|
||||
expect(evaluateApiKeyStrength('abababababababababababababab').tier).toBe('weak');
|
||||
// 尾部不完整的周期同样识别
|
||||
const periodic = evaluateApiKeyStrength('myproxy2024myproxy2024myproxy');
|
||||
const aperiodic = evaluateApiKeyStrength('myproxy2024ZtRv4Ns8Lc3Bd7hQwK');
|
||||
expect(periodic.bits).toBeLessThan(aperiodic.bits);
|
||||
});
|
||||
|
||||
test('guessable tokens drag the score down', () => {
|
||||
const withToken = evaluateApiKeyStrength('sk-password-9fKw2mQx7ZtRv4Ns8Lc3Bd');
|
||||
const withoutToken = evaluateApiKeyStrength('sk-hRvnqtwj-9fKw2mQx7ZtRv4Ns8Lc3Bd');
|
||||
|
||||
expect(withToken.bits).toBeLessThan(withoutToken.bits);
|
||||
expect(TIER_ORDER.indexOf(withToken.tier)).toBeLessThan(TIER_ORDER.indexOf(withoutToken.tier));
|
||||
});
|
||||
|
||||
test('longer keys never score below their prefix', () => {
|
||||
const base = 'Xk7#mQ2vLp9$Rn4wZt6cHj8&Bd5xVy3';
|
||||
let previous = 0;
|
||||
|
||||
for (let length = 1; length <= base.length; length += 1) {
|
||||
const { bits } = evaluateApiKeyStrength(base.slice(0, length));
|
||||
expect(bits).toBeGreaterThanOrEqual(previous);
|
||||
previous = bits;
|
||||
}
|
||||
});
|
||||
});
|
||||
66
frontend/tests/apiKeyStrengthMeter.test.ts
Normal file
66
frontend/tests/apiKeyStrengthMeter.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import i18n from '@/i18n';
|
||||
import { ApiKeyStrengthMeter } from '@/features/config/components/blocks/ApiKeyStrengthMeter';
|
||||
import { SEGMENT_STAGGER_MS, segmentFillDelayMs } from '@/features/config/components/blocks/shared';
|
||||
import { generateSecureApiKey } from '@/utils/apiKey';
|
||||
|
||||
const LOCALES = ['en', 'zh-CN', 'zh-TW', 'ru'];
|
||||
|
||||
describe('ApiKeyStrengthMeter', () => {
|
||||
test('exposes the tier through the progressbar', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(ApiKeyStrengthMeter, { value: generateSecureApiKey() })
|
||||
);
|
||||
|
||||
expect(markup).toContain('aria-valuenow="4"');
|
||||
expect(markup).toContain('aria-valuemax="4"');
|
||||
expect(markup).toContain(
|
||||
`aria-valuetext="${i18n.t('config_management.visual.api_keys.strength.strong')}"`
|
||||
);
|
||||
expect(markup.match(/data-filled="true"/g)).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('empty input lights nothing and shows a placeholder label', () => {
|
||||
const markup = renderToStaticMarkup(createElement(ApiKeyStrengthMeter, { value: '' }));
|
||||
|
||||
expect(markup).toContain('aria-valuenow="0"');
|
||||
expect(markup).not.toContain('data-filled="true"');
|
||||
expect(markup).toContain('—');
|
||||
});
|
||||
|
||||
test('segments cascade only over the newly lit ones', () => {
|
||||
const delays = (segments: number, previous: number) =>
|
||||
[0, 1, 2, 3].map((index) => segmentFillDelayMs(index, segments, previous));
|
||||
|
||||
// 0 → 4(点「生成」):四段依次起跑
|
||||
expect(delays(4, 0)).toEqual([
|
||||
0,
|
||||
SEGMENT_STAGGER_MS,
|
||||
SEGMENT_STAGGER_MS * 2,
|
||||
SEGMENT_STAGGER_MS * 3,
|
||||
]);
|
||||
// 2 → 3(键入一个字符):新增的那段立刻亮,不为它的下标排队
|
||||
expect(delays(3, 2)).toEqual([0, 0, 0, 0]);
|
||||
// 1 → 3:只有新增的两段排队
|
||||
expect(delays(3, 1)).toEqual([0, 0, SEGMENT_STAGGER_MS, 0]);
|
||||
// 4 → 2(删字符):熄灭立即发生
|
||||
expect(delays(2, 4)).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
test('every tier label is translated in all locales', async () => {
|
||||
const original = i18n.language;
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
await i18n.changeLanguage(locale);
|
||||
for (const key of ['label', 'empty', 'weak', 'fair', 'good', 'strong']) {
|
||||
const path = `config_management.visual.api_keys.strength.${key}`;
|
||||
expect(i18n.exists(path)).toBe(true);
|
||||
expect(i18n.t(path)).not.toBe(path);
|
||||
}
|
||||
}
|
||||
|
||||
await i18n.changeLanguage(original);
|
||||
});
|
||||
});
|
||||
186
frontend/tests/authFileIdentity.test.ts
Normal file
186
frontend/tests/authFileIdentity.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { deriveAuthFileIdentity, stripJsonExtension } from '../src/features/authFiles/identity';
|
||||
import type { AuthFileItem } from '../src/types';
|
||||
|
||||
const authFile = (overrides: Partial<AuthFileItem> = {}): AuthFileItem => ({
|
||||
name: 'credential.json',
|
||||
type: 'codex',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('stripJsonExtension', () => {
|
||||
test('strips the extension from a real codex file name', () => {
|
||||
expect(stripJsonExtension('codex-abc12345-user@example.com-team.json')).toBe(
|
||||
'codex-abc12345-user@example.com-team'
|
||||
);
|
||||
});
|
||||
|
||||
test('matches the extension case-insensitively', () => {
|
||||
expect(stripJsonExtension('Antigravity-User@Example.com.JSON')).toBe(
|
||||
'Antigravity-User@Example.com'
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves an extension-less runtime-only id untouched', () => {
|
||||
expect(stripJsonExtension('aistudio-channel-a')).toBe('aistudio-channel-a');
|
||||
});
|
||||
|
||||
test('never strips down to an empty string', () => {
|
||||
expect(stripJsonExtension('.json')).toBe('.json');
|
||||
});
|
||||
|
||||
test('trims surrounding whitespace', () => {
|
||||
expect(stripJsonExtension(' kimi-1712345678901.json ')).toBe('kimi-1712345678901');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAuthFileIdentity', () => {
|
||||
test('leads with the account email and keeps the full name as the secondary row', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(
|
||||
authFile({
|
||||
name: 'codex-abc12345-user@example.com-team.json',
|
||||
email: 'user@example.com',
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
primary: 'user@example.com',
|
||||
kind: 'email',
|
||||
secondary: 'codex-abc12345-user@example.com-team',
|
||||
fullName: 'codex-abc12345-user@example.com-team.json',
|
||||
});
|
||||
});
|
||||
|
||||
test('treats a whitespace-only email as absent', () => {
|
||||
const identity = deriveAuthFileIdentity(
|
||||
authFile({ name: 'kimi-1712345678901.json', email: ' ' })
|
||||
);
|
||||
expect(identity.kind).toBe('fileName');
|
||||
expect(identity.primary).toBe('kimi-1712345678901');
|
||||
});
|
||||
|
||||
test('treats a non-string email as absent (index signature guard)', () => {
|
||||
const identity = deriveAuthFileIdentity(
|
||||
authFile({ name: 'kimi-1712345678901.json', email: 123 as unknown as string })
|
||||
);
|
||||
expect(identity.kind).toBe('fileName');
|
||||
expect(identity.primary).toBe('kimi-1712345678901');
|
||||
});
|
||||
|
||||
test('falls back to the file name and drops the duplicate secondary row', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(authFile({ name: 'kimi-1712345678901.json', type: 'kimi' }))
|
||||
).toEqual({
|
||||
primary: 'kimi-1712345678901',
|
||||
kind: 'fileName',
|
||||
secondary: null,
|
||||
fullName: 'kimi-1712345678901.json',
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the project id when there is no email', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(
|
||||
authFile({ name: 'vertex-my-proj.json', type: 'vertex', projectId: 'my-proj' })
|
||||
)
|
||||
).toEqual({
|
||||
primary: 'my-proj',
|
||||
kind: 'projectId',
|
||||
secondary: 'vertex-my-proj',
|
||||
fullName: 'vertex-my-proj.json',
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers the email over the project id', () => {
|
||||
const identity = deriveAuthFileIdentity(
|
||||
authFile({
|
||||
name: 'vertex-my-proj.json',
|
||||
email: 'sa@project.iam.gserviceaccount.com',
|
||||
projectId: 'my-proj',
|
||||
})
|
||||
);
|
||||
expect(identity.kind).toBe('email');
|
||||
expect(identity.primary).toBe('sa@project.iam.gserviceaccount.com');
|
||||
});
|
||||
|
||||
test('never surfaces the account field — it can be a raw API key', () => {
|
||||
const identity = deriveAuthFileIdentity(
|
||||
authFile({
|
||||
name: 'gemini-apikey.json',
|
||||
type: 'gemini',
|
||||
account: 'sk-live-abcd1234',
|
||||
account_type: 'api_key',
|
||||
})
|
||||
);
|
||||
expect(identity.kind).toBe('fileName');
|
||||
expect(identity.primary).toBe('gemini-apikey');
|
||||
expect(JSON.stringify(identity)).not.toContain('sk-live');
|
||||
});
|
||||
|
||||
test('ignores an oauth account even when it looks like an email (do not add it back to the chain)', () => {
|
||||
const identity = deriveAuthFileIdentity(
|
||||
authFile({
|
||||
name: 'codex-abc12345-user@example.com-team.json',
|
||||
account: 'user@example.com',
|
||||
account_type: 'oauth',
|
||||
})
|
||||
);
|
||||
expect(identity.kind).toBe('fileName');
|
||||
expect(identity.primary).toBe('codex-abc12345-user@example.com-team');
|
||||
expect(identity.secondary).toBeNull();
|
||||
});
|
||||
|
||||
test('suppresses the secondary row for runtime-only entries whose name is the account', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(
|
||||
authFile({
|
||||
name: 'aistudio-channel-a',
|
||||
type: 'aistudio',
|
||||
email: 'aistudio-channel-a',
|
||||
runtimeOnly: true,
|
||||
})
|
||||
).secondary
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('the duplicate guard is case-insensitive', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(authFile({ name: 'USER@X.COM.json', email: 'user@x.com' })).secondary
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the disambiguating secondary row for two credentials sharing one email', () => {
|
||||
const team = deriveAuthFileIdentity(
|
||||
authFile({ name: 'codex-abc12345-user@example.com-team.json', email: 'user@example.com' })
|
||||
);
|
||||
const plus = deriveAuthFileIdentity(
|
||||
authFile({ name: 'codex-abc12345-user@example.com-plus.json', email: 'user@example.com' })
|
||||
);
|
||||
expect(team.primary).toBe(plus.primary);
|
||||
expect(team.secondary).not.toBeNull();
|
||||
expect(team.secondary).not.toBe(plus.secondary);
|
||||
});
|
||||
|
||||
test('does not strip a provider prefix from the secondary row', () => {
|
||||
expect(
|
||||
deriveAuthFileIdentity(
|
||||
authFile({ name: '-abc12345-user@example.com-team.json', email: 'user@example.com' })
|
||||
).secondary
|
||||
).toBe('-abc12345-user@example.com-team');
|
||||
});
|
||||
|
||||
test('handles an empty file name without inventing a placeholder', () => {
|
||||
expect(deriveAuthFileIdentity(authFile({ name: '', email: 'user@x.com' }))).toEqual({
|
||||
primary: 'user@x.com',
|
||||
kind: 'email',
|
||||
secondary: null,
|
||||
fullName: '',
|
||||
});
|
||||
expect(deriveAuthFileIdentity(authFile({ name: '' }))).toEqual({
|
||||
primary: '',
|
||||
kind: 'fileName',
|
||||
secondary: null,
|
||||
fullName: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
44
frontend/tests/authFileProblemStatus.test.ts
Normal file
44
frontend/tests/authFileProblemStatus.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { isProblemAuthFile } from '../src/features/authFiles/constants';
|
||||
import type { AuthFileItem } from '../src/types';
|
||||
|
||||
const authFile = (overrides: Partial<AuthFileItem> = {}): AuthFileItem => ({
|
||||
name: 'credential.json',
|
||||
type: 'codex',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('auth file problem status', () => {
|
||||
test('does not classify a deliberately disabled credential as a problem', () => {
|
||||
expect(
|
||||
isProblemAuthFile(
|
||||
authFile({
|
||||
disabled: true,
|
||||
status: 'disabled',
|
||||
statusMessage: 'disabled via management API',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('also respects the backend disabled status when the boolean is absent', () => {
|
||||
expect(
|
||||
isProblemAuthFile(
|
||||
authFile({
|
||||
status: ' DISABLED ',
|
||||
statusMessage: 'disabled via management API',
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('ignores healthy status messages', () => {
|
||||
expect(isProblemAuthFile(authFile({ status: 'active', statusMessage: 'ok' }))).toBe(false);
|
||||
});
|
||||
|
||||
test('detects warning messages, unavailable credentials, and error status', () => {
|
||||
expect(isProblemAuthFile(authFile({ statusMessage: 'quota exhausted' }))).toBe(true);
|
||||
expect(isProblemAuthFile(authFile({ unavailable: true }))).toBe(true);
|
||||
expect(isProblemAuthFile(authFile({ status: 'error' }))).toBe(true);
|
||||
});
|
||||
});
|
||||
154
frontend/tests/authFileWeight.test.ts
Normal file
154
frontend/tests/authFileWeight.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildAuthFileFieldsPatch,
|
||||
type PrefixProxyEditorState,
|
||||
} from '../src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
|
||||
import { readAuthFileDisableCooling } from '../src/features/authFiles/constants';
|
||||
|
||||
const makeEditor = (json: Record<string, unknown>, weight: string): PrefixProxyEditorState => ({
|
||||
fileName: 'credential.json',
|
||||
fileInfoText: '',
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
originalText: JSON.stringify(json),
|
||||
rawText: JSON.stringify(json),
|
||||
invalidContentPreview: '',
|
||||
json,
|
||||
providerKey: 'codex',
|
||||
prefix: '',
|
||||
proxyUrl: '',
|
||||
priority: '',
|
||||
weight,
|
||||
weightError: null,
|
||||
disableCooling: false,
|
||||
disableCoolingTouched: false,
|
||||
websockets: false,
|
||||
websocketsTouched: false,
|
||||
usingApi: false,
|
||||
usingApiTouched: false,
|
||||
note: '',
|
||||
noteTouched: false,
|
||||
excludedModelsText: '',
|
||||
excludedModelsTouched: false,
|
||||
headersText: '',
|
||||
headersTouched: false,
|
||||
headersError: null,
|
||||
});
|
||||
|
||||
const resolveError = (key: string) => key;
|
||||
|
||||
describe('auth-file credential weight patch', () => {
|
||||
test('writes numeric weight and uses null to restore the default', () => {
|
||||
expect(buildAuthFileFieldsPatch(makeEditor({}, '0'), resolveError)).toEqual({ weight: 0 });
|
||||
expect(buildAuthFileFieldsPatch(makeEditor({ weight: 5 }, ''), resolveError)).toEqual({
|
||||
weight: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('recognizes a numeric string in an existing auth file', () => {
|
||||
expect(buildAuthFileFieldsPatch(makeEditor({ weight: '7' }, '7'), resolveError)).toEqual({});
|
||||
expect(buildAuthFileFieldsPatch(makeEditor({ weight: '7' }, ''), resolveError)).toEqual({
|
||||
weight: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects invalid and oversized values before PATCH', () => {
|
||||
expect(() => buildAuthFileFieldsPatch(makeEditor({}, '1.5'), resolveError)).toThrow(
|
||||
'auth_files.weight_invalid_integer'
|
||||
);
|
||||
expect(() => buildAuthFileFieldsPatch(makeEditor({}, '1000001'), resolveError)).toThrow(
|
||||
'auth_files.weight_invalid_max'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-file disable cooling patch', () => {
|
||||
test('reads canonical and legacy boolean-compatible metadata', () => {
|
||||
expect(readAuthFileDisableCooling({ disable_cooling: 'true' })).toBe(true);
|
||||
expect(readAuthFileDisableCooling({ 'disable-cooling': 1 })).toBe(true);
|
||||
expect(
|
||||
readAuthFileDisableCooling({ disable_cooling: 'invalid', 'disable-cooling': true })
|
||||
).toBe(true);
|
||||
expect(readAuthFileDisableCooling({ disable_cooling: false, 'disable-cooling': true })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('writes the canonical field when enabling the per-credential override', () => {
|
||||
const editor = {
|
||||
...makeEditor({}, ''),
|
||||
disableCooling: true,
|
||||
disableCoolingTouched: true,
|
||||
};
|
||||
|
||||
expect(buildAuthFileFieldsPatch(editor, resolveError)).toEqual({ disable_cooling: true });
|
||||
});
|
||||
|
||||
test('preserves the legacy field name and writes false when disabling it', () => {
|
||||
const editor = {
|
||||
...makeEditor({ 'disable-cooling': true }, ''),
|
||||
disableCooling: false,
|
||||
disableCoolingTouched: true,
|
||||
};
|
||||
|
||||
expect(buildAuthFileFieldsPatch(editor, resolveError)).toEqual({ 'disable-cooling': false });
|
||||
});
|
||||
|
||||
test('does not patch an untouched or unchanged override', () => {
|
||||
expect(buildAuthFileFieldsPatch(makeEditor({ disable_cooling: true }, ''), resolveError)).toEqual(
|
||||
{}
|
||||
);
|
||||
expect(
|
||||
buildAuthFileFieldsPatch(
|
||||
{
|
||||
...makeEditor({ disable_cooling: 'true' }, ''),
|
||||
disableCooling: true,
|
||||
disableCoolingTouched: true,
|
||||
},
|
||||
resolveError
|
||||
)
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth-file excluded models patch', () => {
|
||||
test('writes normalized model patterns to the canonical field', () => {
|
||||
const editor = {
|
||||
...makeEditor({}, ''),
|
||||
excludedModelsText: ' gpt-5-*\nGPT-5-*\nclaude-opus ',
|
||||
excludedModelsTouched: true,
|
||||
};
|
||||
|
||||
expect(buildAuthFileFieldsPatch(editor, resolveError)).toEqual({
|
||||
excluded_models: ['gpt-5-*', 'claude-opus'],
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves the legacy hyphenated field name', () => {
|
||||
const editor = {
|
||||
...makeEditor({ 'excluded-models': ['old-model'] }, ''),
|
||||
excludedModelsText: 'new-model',
|
||||
excludedModelsTouched: true,
|
||||
};
|
||||
|
||||
expect(buildAuthFileFieldsPatch(editor, resolveError)).toEqual({
|
||||
'excluded-models': ['new-model'],
|
||||
});
|
||||
});
|
||||
|
||||
test('clears exclusions with an empty array and ignores untouched values', () => {
|
||||
const original = { excluded_models: ['gpt-5-*'] };
|
||||
expect(buildAuthFileFieldsPatch(makeEditor(original, ''), resolveError)).toEqual({});
|
||||
expect(
|
||||
buildAuthFileFieldsPatch(
|
||||
{
|
||||
...makeEditor(original, ''),
|
||||
excludedModelsText: '',
|
||||
excludedModelsTouched: true,
|
||||
},
|
||||
resolveError
|
||||
)
|
||||
).toEqual({ excluded_models: [] });
|
||||
});
|
||||
});
|
||||
158
frontend/tests/authFilesListLogic.test.ts
Normal file
158
frontend/tests/authFilesListLogic.test.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildWildcardSearch,
|
||||
matchesAuthFileSearch,
|
||||
sortAuthFiles,
|
||||
} from '../src/features/authFiles/logic';
|
||||
import type { AuthFileItem } from '../src/types';
|
||||
|
||||
const authFile = (overrides: Partial<AuthFileItem> = {}): AuthFileItem => ({
|
||||
name: 'credential.json',
|
||||
type: 'codex',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const search = (file: AuthFileItem, term: string) =>
|
||||
matchesAuthFileSearch(file, term, buildWildcardSearch(term));
|
||||
|
||||
describe('buildWildcardSearch', () => {
|
||||
test('returns null when the term has no wildcard', () => {
|
||||
expect(buildWildcardSearch('user@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
test('stays unanchored — segments may match anywhere in the value', () => {
|
||||
expect(buildWildcardSearch('a*b')?.test('zzzaqqbzzz')).toBe(true);
|
||||
});
|
||||
|
||||
test('escapes regex metacharacters in the segments', () => {
|
||||
const pattern = buildWildcardSearch('u+1*');
|
||||
expect(pattern?.test('u+1@x.com')).toBe(true);
|
||||
expect(pattern?.test('u1@x.com')).toBe(false);
|
||||
});
|
||||
|
||||
test('a lone wildcard matches everything', () => {
|
||||
expect(buildWildcardSearch('*')?.test('anything')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesAuthFileSearch', () => {
|
||||
test('an empty term matches every file', () => {
|
||||
expect(matchesAuthFileSearch(authFile(), '', null)).toBe(true);
|
||||
});
|
||||
|
||||
test('matches the file name case-insensitively', () => {
|
||||
expect(search(authFile({ name: 'codex-a.json' }), 'CODEX')).toBe(true);
|
||||
});
|
||||
|
||||
test('matches the type and the provider', () => {
|
||||
expect(search(authFile({ type: 'antigravity' }), 'antigrav')).toBe(true);
|
||||
expect(search(authFile({ type: undefined, provider: 'gemini' }), 'gemi')).toBe(true);
|
||||
});
|
||||
|
||||
test('finds a kimi credential by account email even though its name carries none', () => {
|
||||
expect(
|
||||
search(
|
||||
authFile({ name: 'kimi-1712345678901.json', type: 'kimi', email: 'user@example.com' }),
|
||||
'user@example'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('matches the project id', () => {
|
||||
expect(search(authFile({ name: 'vertex-x.json', projectId: 'my-proj' }), 'my-proj')).toBe(true);
|
||||
});
|
||||
|
||||
test('searches the email on the wildcard path too', () => {
|
||||
expect(
|
||||
search(authFile({ name: 'kimi-1712345678901.json', email: 'user@example.com' }), 'user@*com')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('never matches the account field — it can be a raw API key', () => {
|
||||
expect(
|
||||
search(
|
||||
authFile({ name: 'gemini-apikey.json', account: 'sk-live-abcd', account_type: 'api_key' }),
|
||||
'sk-live'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('tolerates missing fields', () => {
|
||||
expect(search({ name: 'bare.json' }, 'zzz')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortAuthFiles', () => {
|
||||
test("'default' orders by provider then name", () => {
|
||||
const files = [
|
||||
authFile({ name: 'b.json', provider: 'kimi' }),
|
||||
authFile({ name: 'c.json', type: 'codex', provider: undefined }),
|
||||
authFile({ name: 'a.json', provider: 'kimi' }),
|
||||
];
|
||||
expect(sortAuthFiles(files, 'default').map((file) => file.name)).toEqual([
|
||||
'c.json',
|
||||
'a.json',
|
||||
'b.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test("'az' orders by the displayed primary row, not by the file name", () => {
|
||||
const files = [
|
||||
authFile({ name: 'zzz.json', email: 'aaa@example.com' }),
|
||||
authFile({ name: 'aaa.json', email: 'zzz@example.com' }),
|
||||
];
|
||||
expect(sortAuthFiles(files, 'az').map((file) => file.name)).toEqual(['zzz.json', 'aaa.json']);
|
||||
});
|
||||
|
||||
test("'az' falls back to the file name when primaries are equal", () => {
|
||||
const files = [
|
||||
authFile({ name: 'codex-abc-user@x.com-team.json', email: 'user@x.com' }),
|
||||
authFile({ name: 'codex-abc-user@x.com-plus.json', email: 'user@x.com' }),
|
||||
];
|
||||
expect(sortAuthFiles(files, 'az').map((file) => file.name)).toEqual([
|
||||
'codex-abc-user@x.com-plus.json',
|
||||
'codex-abc-user@x.com-team.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test("'az' mixes account primaries and file-name fallbacks coherently", () => {
|
||||
const files = [
|
||||
authFile({ name: 'kimi-9.json', type: 'kimi' }),
|
||||
authFile({ name: 'zzz.json', email: 'aaa@example.com' }),
|
||||
];
|
||||
expect(sortAuthFiles(files, 'az').map((file) => file.name)).toEqual([
|
||||
'zzz.json',
|
||||
'kimi-9.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test("'priority' orders descending, treats missing values as 0 and stays stable on ties", () => {
|
||||
const files = [
|
||||
authFile({ name: 'a.json' }),
|
||||
authFile({ name: 'b.json', priority: 5 }),
|
||||
authFile({ name: 'c.json' }),
|
||||
authFile({ name: 'd.json', priority: 9 }),
|
||||
];
|
||||
expect(sortAuthFiles(files, 'priority').map((file) => file.name)).toEqual([
|
||||
'd.json',
|
||||
'b.json',
|
||||
'a.json',
|
||||
'c.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('returns a new array and leaves the input untouched', () => {
|
||||
const files = [authFile({ name: 'b.json' }), authFile({ name: 'a.json' })];
|
||||
const result = sortAuthFiles(files, 'az');
|
||||
expect(result).not.toBe(files);
|
||||
expect(files.map((file) => file.name)).toEqual(['b.json', 'a.json']);
|
||||
});
|
||||
|
||||
test('an unknown mode returns an unsorted copy', () => {
|
||||
const files = [authFile({ name: 'b.json' }), authFile({ name: 'a.json' })];
|
||||
expect(sortAuthFiles(files, 'nope' as unknown as 'az').map((file) => file.name)).toEqual([
|
||||
'b.json',
|
||||
'a.json',
|
||||
]);
|
||||
});
|
||||
});
|
||||
108
frontend/tests/authFilesResponseNormalization.test.ts
Normal file
108
frontend/tests/authFilesResponseNormalization.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { normalizeAuthFilesResponse } from '../src/services/api/authFiles';
|
||||
import type { AuthFilesResponse } from '../src/types/authFile';
|
||||
|
||||
const responseWithRawFiles = (files: Array<Record<string, unknown>>): AuthFilesResponse =>
|
||||
({ files }) as unknown as AuthFilesResponse;
|
||||
|
||||
describe('auth-files response normalization', () => {
|
||||
test('normalizes WRR weights while preserving zero and negative values', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'positive.json', weight: 5 },
|
||||
{ name: 'string.json', weight: '7' },
|
||||
{ name: 'zero.json', weight: 0 },
|
||||
{ name: 'negative.json', weight: -2 },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files.map((file) => file.weight)).toEqual([-2, 5, 7, 0]);
|
||||
});
|
||||
|
||||
test('omits missing, fractional, and unsafe WRR weights from the normalized field', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'missing.json' },
|
||||
{ name: 'fractional.json', weight: '1.5' },
|
||||
{ name: 'unsafe.json', weight: Number.MAX_SAFE_INTEGER + 1 },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files.map((file) => file.weight)).toEqual([undefined, undefined, undefined]);
|
||||
});
|
||||
|
||||
test('applies the same safe-integer normalization to priority', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'valid.json', priority: '-3' },
|
||||
{ name: 'invalid.json', priority: '3.5' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files.map((file) => file.priority)).toEqual([undefined, -3]);
|
||||
});
|
||||
|
||||
test('surfaces the trimmed account email', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([{ name: 'codex-a.json', email: ' user@example.com ' }])
|
||||
);
|
||||
|
||||
expect(result.files[0]?.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
test('leaves an empty backend email as the raw empty string', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([{ name: 'kimi-1.json', email: '' }])
|
||||
);
|
||||
|
||||
expect(result.files[0]?.email).toBe('');
|
||||
});
|
||||
|
||||
test('normalizes project_id to projectId while keeping the raw key', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([{ name: 'vertex-a.json', project_id: ' my-proj ' }])
|
||||
);
|
||||
|
||||
expect(result.files[0]?.projectId).toBe('my-proj');
|
||||
expect(result.files[0]?.project_id).toBe(' my-proj ');
|
||||
});
|
||||
|
||||
test('recovers a non-empty email from the lower-priority duplicate entry', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'codex-a.json', source: 'file', path: '/auths/codex-a.json', email: '' },
|
||||
{ name: 'codex-a.json', source: 'memory', email: 'user@example.com' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0]?.email).toBe('user@example.com');
|
||||
});
|
||||
|
||||
test('prefers the higher-scored entry when both emails are non-empty and differ', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'codex-a.json', source: 'memory', email: 'stale@example.com' },
|
||||
{
|
||||
name: 'codex-a.json',
|
||||
source: 'file',
|
||||
path: '/auths/codex-a.json',
|
||||
email: 'fresh@example.com',
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files[0]?.email).toBe('fresh@example.com');
|
||||
});
|
||||
|
||||
test('passes account through raw without deriving a camelCase field', () => {
|
||||
const result = normalizeAuthFilesResponse(
|
||||
responseWithRawFiles([
|
||||
{ name: 'gemini-apikey.json', account: 'sk-live-abcd', account_type: 'api_key' },
|
||||
])
|
||||
);
|
||||
|
||||
expect(result.files[0]?.account).toBe('sk-live-abcd');
|
||||
expect(result.files[0]?.accountType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
189
frontend/tests/claudeFableQuota.test.ts
Normal file
189
frontend/tests/claudeFableQuota.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { buildClaudeQuotaWindows } from '@/features/quota/providers/claude/data';
|
||||
import type { ClaudeUsagePayload } from '@/types';
|
||||
import { formatQuotaResetTime } from '@/utils/quota';
|
||||
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
const modernReset = '2026-07-27T10:00:00.000000+00:00';
|
||||
const legacyReset = '2026-07-28T10:00:00.000000+00:00';
|
||||
|
||||
describe('Claude Fable quota', () => {
|
||||
test('builds a Fable window from the modern scoped limits payload', () => {
|
||||
const windows = buildClaudeQuotaWindows(
|
||||
{
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
group: 'weekly',
|
||||
percent: 64,
|
||||
resets_at: modernReset,
|
||||
is_active: true,
|
||||
scope: { model: { id: null, display_name: 'Fable' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
id: 'seven-day-fable',
|
||||
label: 'claude_quota.seven_day_fable',
|
||||
labelKey: 'claude_quota.seven_day_fable',
|
||||
usedPercent: 64,
|
||||
resetLabel: formatQuotaResetTime(modernReset),
|
||||
resetAtMs: Date.parse(modernReset),
|
||||
periodHours: 24 * 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the legacy Fable field', () => {
|
||||
const windows = buildClaudeQuotaWindows(
|
||||
{
|
||||
iguana_necktie: {
|
||||
utilization: 41,
|
||||
resets_at: legacyReset,
|
||||
},
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
id: 'seven-day-fable',
|
||||
label: 'claude_quota.seven_day_fable',
|
||||
labelKey: 'claude_quota.seven_day_fable',
|
||||
usedPercent: 41,
|
||||
resetLabel: formatQuotaResetTime(legacyReset),
|
||||
resetAtMs: Date.parse(legacyReset),
|
||||
periodHours: 24 * 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to the legacy field when the modern percent is invalid', () => {
|
||||
const windows = buildClaudeQuotaWindows(
|
||||
{
|
||||
iguana_necktie: {
|
||||
utilization: 41,
|
||||
resets_at: legacyReset,
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: null,
|
||||
resets_at: modernReset,
|
||||
is_active: true,
|
||||
scope: { model: { display_name: 'Fable' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
id: 'seven-day-fable',
|
||||
label: 'claude_quota.seven_day_fable',
|
||||
labelKey: 'claude_quota.seven_day_fable',
|
||||
usedPercent: 41,
|
||||
resetLabel: formatQuotaResetTime(legacyReset),
|
||||
resetAtMs: Date.parse(legacyReset),
|
||||
periodHours: 24 * 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('prefers the active modern field without rendering a duplicate', () => {
|
||||
const windows = buildClaudeQuotaWindows(
|
||||
{
|
||||
iguana_necktie: {
|
||||
utilization: 41,
|
||||
resets_at: legacyReset,
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: 12,
|
||||
resets_at: legacyReset,
|
||||
is_active: false,
|
||||
scope: { model: { display_name: 'Fable 5' } },
|
||||
},
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: 64,
|
||||
resets_at: modernReset,
|
||||
is_active: true,
|
||||
scope: { model: { display_name: 'Fable' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0]).toMatchObject({
|
||||
id: 'seven-day-fable',
|
||||
usedPercent: 64,
|
||||
resetLabel: formatQuotaResetTime(modernReset),
|
||||
});
|
||||
});
|
||||
|
||||
test('uses a valid modern candidate when the preferred candidate is invalid', () => {
|
||||
const windows = buildClaudeQuotaWindows(
|
||||
{
|
||||
limits: [
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: null,
|
||||
resets_at: legacyReset,
|
||||
is_active: true,
|
||||
scope: { model: { display_name: 'Fable' } },
|
||||
},
|
||||
{
|
||||
kind: 'weekly_scoped',
|
||||
percent: 64,
|
||||
resets_at: modernReset,
|
||||
is_active: false,
|
||||
scope: { model: { display_name: 'Fable' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(windows).toEqual([
|
||||
{
|
||||
id: 'seven-day-fable',
|
||||
label: 'claude_quota.seven_day_fable',
|
||||
labelKey: 'claude_quota.seven_day_fable',
|
||||
usedPercent: 64,
|
||||
resetLabel: formatQuotaResetTime(modernReset),
|
||||
resetAtMs: Date.parse(modernReset),
|
||||
periodHours: 24 * 7,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores malformed and unrelated limits while preserving standard windows', () => {
|
||||
const payload = {
|
||||
five_hour: { utilization: 10, resets_at: null },
|
||||
seven_day: { utilization: 20, resets_at: legacyReset },
|
||||
limits: [
|
||||
null,
|
||||
{ kind: 'weekly_scoped', percent: 35, scope: { model: { display_name: 'Sonnet' } } },
|
||||
{ kind: 'session', percent: 50, scope: { model: { display_name: 'Fable' } } },
|
||||
{ kind: 'weekly_scoped', percent: null, scope: { model: { display_name: 'Fable' } } },
|
||||
],
|
||||
} as unknown as ClaudeUsagePayload;
|
||||
|
||||
const windows = buildClaudeQuotaWindows(payload, t);
|
||||
|
||||
expect(windows.map(({ id, usedPercent }) => ({ id, usedPercent }))).toEqual([
|
||||
{ id: 'five-hour', usedPercent: 10 },
|
||||
{ id: 'seven-day', usedPercent: 20 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
89
frontend/tests/codexQuota.test.ts
Normal file
89
frontend/tests/codexQuota.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { CODEX_CONFIG, buildCodexQuotaWindows } from '@/features/quota/providers/codex/data';
|
||||
import type { CodexQuotaState, CodexUsagePayload } from '@/types';
|
||||
import { normalizeCodexResetCreditsPayload, parseCodexUsagePayload } from '@/utils/quota';
|
||||
|
||||
const t = ((key: string) => key) as TFunction;
|
||||
|
||||
const CURRENT_CODEX_USAGE_PAYLOAD: CodexUsagePayload = {
|
||||
plan_type: 'pro',
|
||||
rate_limit: {
|
||||
allowed: true,
|
||||
limit_reached: false,
|
||||
primary_window: {
|
||||
used_percent: 1,
|
||||
limit_window_seconds: 604800,
|
||||
reset_after_seconds: 601888,
|
||||
reset_at: 1785902974,
|
||||
},
|
||||
secondary_window: null,
|
||||
},
|
||||
code_review_rate_limit: null,
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: 'GPT-5.3-Codex-Spark',
|
||||
metered_feature: 'codex_bengalfox',
|
||||
rate_limit: {
|
||||
allowed: true,
|
||||
limit_reached: false,
|
||||
primary_window: {
|
||||
used_percent: 0,
|
||||
limit_window_seconds: 604800,
|
||||
reset_after_seconds: 602111,
|
||||
reset_at: 1785903197,
|
||||
},
|
||||
secondary_window: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 1,
|
||||
applicable_available_count: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe('Codex current usage payload', () => {
|
||||
test('parses the proxied JSON body and classifies both primary weekly windows', () => {
|
||||
const payload = parseCodexUsagePayload(JSON.stringify(CURRENT_CODEX_USAGE_PAYLOAD));
|
||||
expect(payload).not.toBeNull();
|
||||
|
||||
const windows = buildCodexQuotaWindows(payload!, t);
|
||||
|
||||
expect(windows.map(({ id }) => id)).toEqual(['weekly', 'gpt-5-3-codex-spark-weekly-0']);
|
||||
expect(windows.map(({ labelKey }) => labelKey)).toEqual([
|
||||
'codex_quota.secondary_window',
|
||||
'codex_quota.additional_secondary_window',
|
||||
]);
|
||||
expect(windows.map(({ usedPercent }) => usedPercent)).toEqual([1, 0]);
|
||||
expect(windows[1]?.labelParams).toEqual({ name: 'GPT-5.3-Codex-Spark' });
|
||||
});
|
||||
|
||||
test('shows reset support when total credits remain but none currently apply', () => {
|
||||
const summary = normalizeCodexResetCreditsPayload(
|
||||
CURRENT_CODEX_USAGE_PAYLOAD.rate_limit_reset_credits
|
||||
);
|
||||
|
||||
expect(summary.invalidPayload).toBe(false);
|
||||
expect(summary.availableCount).toBe(1);
|
||||
expect(summary.applicableAvailableCount).toBe(0);
|
||||
|
||||
const quota: CodexQuotaState = {
|
||||
status: 'success',
|
||||
windows: [],
|
||||
rateLimitResetCreditsAvailableCount: summary.availableCount,
|
||||
rateLimitResetCreditsApplicableAvailableCount: summary.applicableAvailableCount,
|
||||
};
|
||||
expect(CODEX_CONFIG.canResetQuota?.(quota)).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps reset support for legacy payloads without applicable count', () => {
|
||||
const quota: CodexQuotaState = {
|
||||
status: 'success',
|
||||
windows: [],
|
||||
rateLimitResetCreditsAvailableCount: 1,
|
||||
};
|
||||
|
||||
expect(CODEX_CONFIG.canResetQuota?.(quota)).toBe(true);
|
||||
});
|
||||
});
|
||||
132
frontend/tests/configFieldParity.test.ts
Normal file
132
frontend/tests/configFieldParity.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// 配置项零遗漏守卫:三方对账
|
||||
// ① 搜索索引(searchIndex.ts,唯一的机器可读字段清单)
|
||||
// ② 值键映射(constants.ts FIELD_VALUE_KEYS ↔ VisualConfigValues 叶值键)
|
||||
// ③ 分区 JSX 里实际渲染的 <FieldAnchor fieldId="…"> 锚点(源码扫描)
|
||||
// 任何一方增删字段而漏改其余两方,本套件即红。
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
COMMON_FIELD_IDS,
|
||||
CONFIG_SECTION_IDS,
|
||||
CONFIG_TAB_IDS,
|
||||
FIELD_VALUE_KEYS,
|
||||
SECTION_VALIDATION_FIELDS,
|
||||
} from '@/features/config/constants';
|
||||
import { CONFIG_FIELD_SEARCH_INDEX } from '@/features/config/searchIndex';
|
||||
import { getVisualConfigValidationErrors } from '@/hooks/useVisualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '@/types/visualConfig';
|
||||
|
||||
const INDEX_FIELD_IDS = CONFIG_FIELD_SEARCH_INDEX.map((entry) => entry.fieldId);
|
||||
const INDEX_FIELD_ID_SET = new Set(INDEX_FIELD_IDS);
|
||||
|
||||
/** VisualConfigValues 的叶值键:顶层标量 + streaming 展开为点号叶(= dirtyFields 的键域)。 */
|
||||
const LEAF_VALUE_KEYS = new Set(
|
||||
Object.keys(DEFAULT_VISUAL_VALUES).flatMap((key) =>
|
||||
key === 'streaming'
|
||||
? Object.keys(DEFAULT_VISUAL_VALUES.streaming).map((leaf) => `streaming.${leaf}`)
|
||||
: [key]
|
||||
)
|
||||
);
|
||||
|
||||
const sorted = (values: Iterable<string>) => [...values].sort();
|
||||
|
||||
describe('search index integrity', () => {
|
||||
test('field ids are unique', () => {
|
||||
expect(INDEX_FIELD_ID_SET.size).toBe(INDEX_FIELD_IDS.length);
|
||||
});
|
||||
|
||||
test('every entry belongs to a canonical section', () => {
|
||||
const sectionIds = new Set<string>(CONFIG_SECTION_IDS);
|
||||
for (const entry of CONFIG_FIELD_SEARCH_INDEX) {
|
||||
expect(sectionIds.has(entry.sectionId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('label / qualifier / hint keys resolve to strings in en.json', async () => {
|
||||
const json = JSON.parse(readFileSync('src/i18n/locales/en.json', 'utf8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const resolveKey = (path: string): unknown =>
|
||||
path.split('.').reduce<unknown>((node, part) => {
|
||||
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, json);
|
||||
|
||||
for (const entry of CONFIG_FIELD_SEARCH_INDEX) {
|
||||
expect(typeof resolveKey(entry.labelKey)).toBe('string');
|
||||
if (entry.qualifierKey) expect(typeof resolveKey(entry.qualifierKey)).toBe('string');
|
||||
if (entry.hintKey) expect(typeof resolveKey(entry.hintKey)).toBe('string');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('value-key coverage (index ↔ VisualConfigValues)', () => {
|
||||
test('FIELD_VALUE_KEYS keys are exactly the index field ids', () => {
|
||||
expect(sorted(Object.keys(FIELD_VALUE_KEYS))).toEqual(sorted(INDEX_FIELD_ID_SET));
|
||||
});
|
||||
|
||||
test('the union of mapped value keys is exactly the VisualConfigValues leaf keys', () => {
|
||||
const mapped = new Set(Object.values(FIELD_VALUE_KEYS).flat());
|
||||
// 双向:漏映射的表单键 / 指向不存在键的映射,都在这里现形
|
||||
expect(sorted(mapped)).toEqual(sorted(LEAF_VALUE_KEYS));
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSX anchor parity (source scan)', () => {
|
||||
test('every index entry is rendered by exactly the section JSX, and vice versa', () => {
|
||||
const componentsDir = fileURLToPath(
|
||||
new URL('../src/features/config/components', import.meta.url)
|
||||
);
|
||||
const sectionsDir = join(componentsDir, 'sections');
|
||||
const scannedFiles = [
|
||||
...readdirSync(sectionsDir)
|
||||
.filter((name) => name.endsWith('.tsx'))
|
||||
.map((name) => join(sectionsDir, name)),
|
||||
join(componentsDir, 'fields/sharedFields.tsx'),
|
||||
];
|
||||
|
||||
const renderedFieldIds = new Set<string>();
|
||||
for (const filePath of scannedFiles) {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
for (const match of source.matchAll(/fieldId="([^"]+)"/g)) {
|
||||
renderedFieldIds.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const missingFromJsx = sorted(INDEX_FIELD_IDS).filter((id) => !renderedFieldIds.has(id));
|
||||
const unknownInJsx = sorted(renderedFieldIds).filter((id) => !INDEX_FIELD_ID_SET.has(id));
|
||||
|
||||
// 分区 JSX 静默丢字段 → missingFromJsx 非空;新增字段没进索引 → unknownInJsx 非空
|
||||
expect(missingFromJsx).toEqual([]);
|
||||
expect(unknownInJsx).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registry consistency', () => {
|
||||
test('tab id registry is common + the seven canonical sections', () => {
|
||||
expect([...CONFIG_TAB_IDS]).toEqual(['common', ...CONFIG_SECTION_IDS]);
|
||||
});
|
||||
|
||||
test('every validation field path lives in exactly one section bucket', () => {
|
||||
const allPaths = Object.keys(getVisualConfigValidationErrors(DEFAULT_VISUAL_VALUES)).sort();
|
||||
const bucketed = Object.values(SECTION_VALIDATION_FIELDS).flat();
|
||||
expect(new Set(bucketed).size).toBe(bucketed.length); // 不允许一个字段进两个桶
|
||||
expect(sorted(bucketed)).toEqual(allPaths);
|
||||
});
|
||||
|
||||
test('validation field paths are real value keys', () => {
|
||||
for (const path of Object.values(SECTION_VALIDATION_FIELDS).flat()) {
|
||||
expect(LEAF_VALUE_KEYS.has(path)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('common tab fields are a subset of the index', () => {
|
||||
for (const fieldId of COMMON_FIELD_IDS) {
|
||||
expect(INDEX_FIELD_ID_SET.has(fieldId)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
27
frontend/tests/configTabsAccessibility.test.ts
Normal file
27
frontend/tests/configTabsAccessibility.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import i18n from '@/i18n';
|
||||
import { ConfigTabs } from '@/features/config/components/ConfigTabs';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('ConfigTabs accessibility', () => {
|
||||
test('announces validation errors and unsaved changes in the tab name', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(ConfigTabs, {
|
||||
active: 'common',
|
||||
errorCounts: { streaming: 2 },
|
||||
dirtyTabs: new Set(['streaming']),
|
||||
onChange: noop,
|
||||
})
|
||||
);
|
||||
const accessibleLabel = [
|
||||
i18n.t('config_management.visual.sections.streaming.title'),
|
||||
i18n.t('config_management.meta_errors', { count: 2 }),
|
||||
i18n.t('config_management.status_dirty_short'),
|
||||
].join(', ');
|
||||
|
||||
expect(markup).toContain(`aria-label="${accessibleLabel}"`);
|
||||
});
|
||||
});
|
||||
223
frontend/tests/configUiState.test.ts
Normal file
223
frontend/tests/configUiState.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildHeaderMeta,
|
||||
countSectionErrors,
|
||||
countTotalErrors,
|
||||
readSavedMode,
|
||||
readSavedSection,
|
||||
resolveDirtyTabs,
|
||||
resolveStatus,
|
||||
type ConfigStatusInput,
|
||||
} from '@/features/config/uiState';
|
||||
import type { VisualConfigValidationErrors } from '@/types/visualConfig';
|
||||
|
||||
const statusInput = (overrides: Partial<ConfigStatusInput> = {}): ConfigStatusInput => ({
|
||||
disconnected: false,
|
||||
loading: false,
|
||||
loadFailed: false,
|
||||
yamlError: false,
|
||||
validationBlocked: false,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('resolveStatus', () => {
|
||||
test('follows the legacy precedence chain top-down', () => {
|
||||
// disconnected > loading > load_failed > yaml_error > validation_blocked > saving > dirty > synced
|
||||
expect(resolveStatus(statusInput({ disconnected: true, loading: true })).key).toBe(
|
||||
'disconnected'
|
||||
);
|
||||
expect(resolveStatus(statusInput({ loading: true, loadFailed: true })).key).toBe('loading');
|
||||
expect(resolveStatus(statusInput({ loadFailed: true, yamlError: true })).key).toBe(
|
||||
'load_failed'
|
||||
);
|
||||
expect(resolveStatus(statusInput({ yamlError: true, validationBlocked: true })).key).toBe(
|
||||
'yaml_error'
|
||||
);
|
||||
expect(resolveStatus(statusInput({ validationBlocked: true, saving: true })).key).toBe(
|
||||
'validation_blocked'
|
||||
);
|
||||
expect(resolveStatus(statusInput({ saving: true, dirty: true })).key).toBe('saving');
|
||||
expect(resolveStatus(statusInput({ dirty: true })).key).toBe('dirty');
|
||||
expect(resolveStatus(statusInput()).key).toBe('synced');
|
||||
});
|
||||
|
||||
test('validation_blocked short key lives at config_management top level (regression: old .visual. path bug)', () => {
|
||||
const status = resolveStatus(statusInput({ validationBlocked: true }));
|
||||
expect(status.shortLabelKey).toBe('config_management.validation_blocked_short');
|
||||
expect(status.labelKey).toBe('config_management.visual.validation.validation_blocked');
|
||||
expect(status.tone).toBe('error');
|
||||
});
|
||||
|
||||
test('every status resolves label keys that exist in all four locales', async () => {
|
||||
const locales = ['en', 'zh-CN', 'zh-TW', 'ru'];
|
||||
const inputs: Partial<ConfigStatusInput>[] = [
|
||||
{ disconnected: true },
|
||||
{ loading: true },
|
||||
{ loadFailed: true },
|
||||
{ yamlError: true },
|
||||
{ validationBlocked: true },
|
||||
{ saving: true },
|
||||
{ dirty: true },
|
||||
{},
|
||||
];
|
||||
for (const locale of locales) {
|
||||
const json = JSON.parse(
|
||||
readFileSync(`src/i18n/locales/${locale}.json`, 'utf8')
|
||||
) as Record<string, unknown>;
|
||||
const resolveKey = (path: string): unknown =>
|
||||
path.split('.').reduce<unknown>((node, part) => {
|
||||
if (node && typeof node === 'object') return (node as Record<string, unknown>)[part];
|
||||
return undefined;
|
||||
}, json);
|
||||
for (const overrides of inputs) {
|
||||
const status = resolveStatus(statusInput(overrides));
|
||||
expect(typeof resolveKey(status.labelKey)).toBe('string');
|
||||
expect(typeof resolveKey(status.shortLabelKey)).toBe('string');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('countSectionErrors', () => {
|
||||
test('buckets field errors by section and mirrors common-tab fields', () => {
|
||||
const errors: VisualConfigValidationErrors = {
|
||||
port: 'port_range',
|
||||
requestRetry: 'non_negative_integer',
|
||||
'streaming.keepaliveSeconds': 'non_negative_integer',
|
||||
};
|
||||
const counts = countSectionErrors(errors, false);
|
||||
expect(counts.connectivity).toBe(1);
|
||||
expect(counts.network).toBe(1);
|
||||
expect(counts.streaming).toBe(1);
|
||||
expect(counts.logging).toBe(0);
|
||||
expect(counts.quota).toBe(0);
|
||||
expect(counts.advanced).toBe(0);
|
||||
expect(counts.payload).toBe(0);
|
||||
// port 由常用 tab 渲染,同一错误在两个 tab 都要可见
|
||||
expect(counts.common).toBe(1);
|
||||
});
|
||||
|
||||
test('payload flag adds one to the payload tab only', () => {
|
||||
const counts = countSectionErrors(undefined, true);
|
||||
expect(counts.payload).toBe(1);
|
||||
expect(counts.common).toBe(0);
|
||||
expect(counts.connectivity).toBe(0);
|
||||
});
|
||||
|
||||
test('undefined error entries do not count', () => {
|
||||
const errors: VisualConfigValidationErrors = { port: undefined };
|
||||
const counts = countSectionErrors(errors, false);
|
||||
expect(counts.connectivity).toBe(0);
|
||||
expect(counts.common).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countTotalErrors', () => {
|
||||
test('sums field errors plus the payload flag', () => {
|
||||
const errors: VisualConfigValidationErrors = {
|
||||
port: 'port_range',
|
||||
maxRetryInterval: 'non_negative_integer',
|
||||
logsMaxTotalSizeMb: undefined,
|
||||
};
|
||||
expect(countTotalErrors(errors, false)).toBe(2);
|
||||
expect(countTotalErrors(errors, true)).toBe(3);
|
||||
expect(countTotalErrors(undefined, false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDirtyTabs', () => {
|
||||
test('maps dirty value keys to their canonical sections', () => {
|
||||
const tabs = resolveDirtyTabs(new Set(['rmSecretKey', 'streaming.bootstrapRetries']));
|
||||
expect(tabs.has('connectivity')).toBe(true);
|
||||
expect(tabs.has('streaming')).toBe(true);
|
||||
expect(tabs.has('common')).toBe(false);
|
||||
expect(tabs.size).toBe(2);
|
||||
});
|
||||
|
||||
test('a common field lights both the common tab and its canonical section', () => {
|
||||
const tabs = resolveDirtyTabs(new Set(['apiKeysText']));
|
||||
expect(tabs.has('common')).toBe(true);
|
||||
expect(tabs.has('connectivity')).toBe(true);
|
||||
expect(tabs.size).toBe(2);
|
||||
|
||||
const quotaTabs = resolveDirtyTabs(new Set(['quotaSwitchProject']));
|
||||
expect(quotaTabs.has('common')).toBe(true);
|
||||
expect(quotaTabs.has('quota')).toBe(true);
|
||||
});
|
||||
|
||||
test('unknown keys are ignored instead of crashing', () => {
|
||||
const tabs = resolveDirtyTabs(new Set(['not-a-real-key']));
|
||||
expect(tabs.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildHeaderMeta', () => {
|
||||
const base = {
|
||||
fieldCount: 58,
|
||||
status: resolveStatus(statusInput()),
|
||||
dirtyCount: 0,
|
||||
sourceDirty: false,
|
||||
errorCount: 0,
|
||||
};
|
||||
|
||||
test('blocking statuses come directly from the page status machine', () => {
|
||||
for (const key of ['disconnected', 'loading', 'load_failed'] as const) {
|
||||
const status = resolveStatus(
|
||||
statusInput({
|
||||
disconnected: key === 'disconnected',
|
||||
loading: key === 'loading',
|
||||
loadFailed: key === 'load_failed',
|
||||
})
|
||||
);
|
||||
const meta = buildHeaderMeta({ ...base, status, dirtyCount: 3 });
|
||||
expect(meta.map((segment) => segment.key)).toEqual(['fields', key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('clean state ends with a synced segment', () => {
|
||||
const meta = buildHeaderMeta(base);
|
||||
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'synced']);
|
||||
expect(meta[0].count).toBe(58);
|
||||
});
|
||||
|
||||
test('dirty and errors stack after the field count', () => {
|
||||
const status = resolveStatus(statusInput({ validationBlocked: true, dirty: true }));
|
||||
const meta = buildHeaderMeta({ ...base, status, dirtyCount: 3, errorCount: 2 });
|
||||
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'dirty', 'errors']);
|
||||
expect(meta[1].count).toBe(3);
|
||||
expect(meta[1].tone).toBe('warning');
|
||||
expect(meta[2].count).toBe(2);
|
||||
expect(meta[2].tone).toBe('error');
|
||||
});
|
||||
|
||||
test('source dirty supersedes the visual dirty count', () => {
|
||||
const status = resolveStatus(statusInput({ dirty: true }));
|
||||
const meta = buildHeaderMeta({ ...base, status, dirtyCount: 3, sourceDirty: true });
|
||||
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'dirty_source']);
|
||||
});
|
||||
|
||||
test('yaml error shows without a synced tail', () => {
|
||||
const status = resolveStatus(statusInput({ yamlError: true }));
|
||||
const meta = buildHeaderMeta({ ...base, status });
|
||||
expect(meta.map((segment) => segment.key)).toEqual(['fields', 'yaml_error']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('localStorage readers', () => {
|
||||
test('readSavedMode falls back to visual on unknown values', () => {
|
||||
expect(readSavedMode('source')).toBe('source');
|
||||
expect(readSavedMode('visual')).toBe('visual');
|
||||
expect(readSavedMode('full')).toBe('visual'); // 旧「简单/完整」值域不再合法
|
||||
expect(readSavedMode(null)).toBe('visual');
|
||||
});
|
||||
|
||||
test('readSavedSection falls back to common on stale values', () => {
|
||||
expect(readSavedSection('payload')).toBe('payload');
|
||||
expect(readSavedSection('common')).toBe('common');
|
||||
expect(readSavedSection('server')).toBe('common'); // 历史分区 id 不再存在
|
||||
expect(readSavedSection(null)).toBe('common');
|
||||
});
|
||||
});
|
||||
32
frontend/tests/credentialWeight.test.ts
Normal file
32
frontend/tests/credentialWeight.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
MAX_CREDENTIAL_WEIGHT,
|
||||
parseCredentialWeightText,
|
||||
readCredentialWeight,
|
||||
validateCredentialWeightText,
|
||||
} from '../src/utils/credentialWeight';
|
||||
|
||||
describe('credential weight validation', () => {
|
||||
test('accepts the default range and non-positive scheduling exclusions', () => {
|
||||
expect(parseCredentialWeightText('')).toBeUndefined();
|
||||
expect(parseCredentialWeightText('1')).toBe(1);
|
||||
expect(parseCredentialWeightText('0')).toBe(0);
|
||||
expect(parseCredentialWeightText('-2')).toBe(-2);
|
||||
expect(parseCredentialWeightText(String(MAX_CREDENTIAL_WEIGHT))).toBe(MAX_CREDENTIAL_WEIGHT);
|
||||
});
|
||||
|
||||
test('rejects non-integers and values above the backend maximum', () => {
|
||||
expect(validateCredentialWeightText('1.5')).toBe('integer');
|
||||
expect(validateCredentialWeightText('1e3')).toBe('integer');
|
||||
expect(validateCredentialWeightText(String(MAX_CREDENTIAL_WEIGHT + 1))).toBe('max');
|
||||
expect(parseCredentialWeightText('1.5')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('reads only valid numeric response fields', () => {
|
||||
expect(readCredentialWeight(7)).toBe(7);
|
||||
expect(readCredentialWeight(0)).toBe(0);
|
||||
expect(readCredentialWeight(' 7 ')).toBe(7);
|
||||
expect(readCredentialWeight('7.5')).toBeUndefined();
|
||||
expect(readCredentialWeight(MAX_CREDENTIAL_WEIGHT + 1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
133
frontend/tests/dashboardMetrics.test.ts
Normal file
133
frontend/tests/dashboardMetrics.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { formatCompactNumber, formatPercent } from '../src/utils/format';
|
||||
import { getProviderKeyCounts } from '../src/features/dashboard/hooks/useDashboardOverview';
|
||||
import {
|
||||
axisMax,
|
||||
niceCeil,
|
||||
providerLabel,
|
||||
splitWindowMinutes,
|
||||
toneForSuccessRate,
|
||||
} from '../src/features/dashboard/utils';
|
||||
|
||||
describe('formatCompactNumber', () => {
|
||||
test('leaves values below one thousand alone', () => {
|
||||
expect(formatCompactNumber(0)).toBe('0');
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
test('compacts with a single decimal and no redundant .0', () => {
|
||||
expect(formatCompactNumber(1000)).toBe('1K');
|
||||
expect(formatCompactNumber(1284)).toBe('1.3K');
|
||||
expect(formatCompactNumber(12_900)).toBe('12.9K');
|
||||
expect(formatCompactNumber(1_500_000)).toBe('1.5M');
|
||||
});
|
||||
|
||||
test('carries into the next tier instead of rendering 1000K', () => {
|
||||
expect(formatCompactNumber(999_999)).toBe('1M');
|
||||
expect(formatCompactNumber(999_999_999)).toBe('1B');
|
||||
});
|
||||
|
||||
test('keeps the sign and survives non-finite input', () => {
|
||||
expect(formatCompactNumber(-1500)).toBe('-1.5K');
|
||||
expect(formatCompactNumber(Number.NaN)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercent', () => {
|
||||
test('trims trailing zeros but keeps meaningful decimals', () => {
|
||||
expect(formatPercent(100)).toBe('100%');
|
||||
expect(formatPercent(99.5)).toBe('99.5%');
|
||||
expect(formatPercent(0)).toBe('0%');
|
||||
});
|
||||
|
||||
test('renders an em dash for non-finite rates', () => {
|
||||
expect(formatPercent(Number.NaN)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('niceCeil', () => {
|
||||
test('rounds up onto the step ladder', () => {
|
||||
expect(niceCeil(1)).toBe(1);
|
||||
expect(niceCeil(7)).toBe(8);
|
||||
expect(niceCeil(12)).toBe(15);
|
||||
expect(niceCeil(48)).toBe(50);
|
||||
expect(niceCeil(320)).toBe(400);
|
||||
});
|
||||
|
||||
test('never returns zero, so bar heights cannot divide by zero', () => {
|
||||
expect(niceCeil(0)).toBe(1);
|
||||
expect(niceCeil(-5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('axisMax', () => {
|
||||
test('lands every gridline on a whole number', () => {
|
||||
// 峰值 112 → 上限 120(刻度 0/30/60/90/120),而不是浪费半张图的 200
|
||||
expect(axisMax(112, 4)).toBe(120);
|
||||
expect(axisMax(7, 4)).toBe(8);
|
||||
expect(axisMax(1533, 4)).toBe(1600);
|
||||
});
|
||||
|
||||
test('keeps the axis just above the peak', () => {
|
||||
for (const peak of [1, 3, 9, 17, 64, 112, 250, 999, 4321]) {
|
||||
const max = axisMax(peak, 4);
|
||||
expect(max).toBeGreaterThanOrEqual(peak);
|
||||
// 上限不应超过峰值的两倍,否则柱子被压得太矮。
|
||||
// 峰值极小时受「每格至少 1」约束,下限就是间隔数本身。
|
||||
expect(max).toBeLessThanOrEqual(Math.max(4, peak * 2));
|
||||
// 每格都必须是整数
|
||||
expect(Number.isInteger(max / 4)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('degrades safely with no traffic', () => {
|
||||
expect(axisMax(0, 4)).toBe(4);
|
||||
expect(axisMax(-3, 4)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toneForSuccessRate', () => {
|
||||
test('maps a rate onto a severity band', () => {
|
||||
expect(toneForSuccessRate(null)).toBe('idle');
|
||||
expect(toneForSuccessRate(100)).toBe('good');
|
||||
expect(toneForSuccessRate(95)).toBe('good');
|
||||
expect(toneForSuccessRate(94.9)).toBe('warning');
|
||||
expect(toneForSuccessRate(80)).toBe('warning');
|
||||
expect(toneForSuccessRate(79.9)).toBe('critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitWindowMinutes', () => {
|
||||
test('splits the rolling window into hours and minutes', () => {
|
||||
expect(splitWindowMinutes(200)).toEqual({ hours: 3, minutes: 20 });
|
||||
expect(splitWindowMinutes(60)).toEqual({ hours: 1, minutes: 0 });
|
||||
expect(splitWindowMinutes(40)).toEqual({ hours: 0, minutes: 40 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider key counts', () => {
|
||||
test('includes native Interactions API keys in the dashboard total inputs', () => {
|
||||
const counts = getProviderKeyCounts({
|
||||
geminiApiKeys: [{ apiKey: 'gemini-key' }],
|
||||
interactionsApiKeys: [{ apiKey: 'interactions-1' }, { apiKey: 'interactions-2' }],
|
||||
codexApiKeys: [{ apiKey: 'codex-key' }],
|
||||
});
|
||||
|
||||
expect(counts.interactions).toBe(2);
|
||||
expect(Object.values(counts).reduce((sum, count) => sum + count, 0)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('providerLabel', () => {
|
||||
test('uses the brand spelling for known providers', () => {
|
||||
expect(providerLabel('xai', 'Unattributed')).toBe('xAI');
|
||||
expect(providerLabel('aistudio', 'Unattributed')).toBe('AI Studio');
|
||||
expect(providerLabel('gemini-interactions', 'Unattributed')).toBe('Interactions API');
|
||||
});
|
||||
|
||||
test('falls back to a capitalised id, and localises unknown', () => {
|
||||
expect(providerLabel('somenewbrand', 'Unattributed')).toBe('Somenewbrand');
|
||||
expect(providerLabel('unknown', 'Unattributed')).toBe('Unattributed');
|
||||
expect(providerLabel('', 'Unattributed')).toBe('Unattributed');
|
||||
});
|
||||
});
|
||||
148
frontend/tests/excludedModelRuleMatching.test.ts
Normal file
148
frontend/tests/excludedModelRuleMatching.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
getModelExclusionState,
|
||||
isModelExcluded,
|
||||
matchedModelsByRule,
|
||||
summarizeExclusion,
|
||||
} from '../src/components/excludedModels/excludedModelRules';
|
||||
|
||||
const CATALOG = ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro', 'claude-opus', 'gemini-3-pro'];
|
||||
|
||||
describe('getModelExclusionState', () => {
|
||||
test('included when no rule touches the model', () => {
|
||||
expect(getModelExclusionState(['claude-opus'], 'gpt-5-mini')).toEqual({ state: 'included' });
|
||||
});
|
||||
|
||||
test('exact when only a literal rule matches', () => {
|
||||
expect(getModelExclusionState(['gpt-5-mini'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
test('wildcard carries the responsible rule so the row can explain itself', () => {
|
||||
expect(getModelExclusionState(['gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'wildcard',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('both when a model is explicitly picked AND caught by a wildcard', () => {
|
||||
expect(getModelExclusionState(['gpt-5-mini', 'gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'both',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('order of the rules does not change the resolved state', () => {
|
||||
expect(getModelExclusionState(['gpt-5-*', 'gpt-5-mini'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'both',
|
||||
rule: 'gpt-5-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('reports the first matching wildcard when several apply', () => {
|
||||
expect(getModelExclusionState(['gpt-*', 'gpt-5-*'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'wildcard',
|
||||
rule: 'gpt-*',
|
||||
});
|
||||
});
|
||||
|
||||
test('matching is case-insensitive in both directions', () => {
|
||||
expect(getModelExclusionState(['GPT-5-MINI'], 'gpt-5-mini')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
expect(getModelExclusionState(['gpt-5-mini'], 'GPT-5-MINI')).toEqual({
|
||||
state: 'excluded',
|
||||
by: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
test('a blank model id is never excluded', () => {
|
||||
expect(getModelExclusionState(['*-mini'], ' ')).toEqual({ state: 'included' });
|
||||
});
|
||||
|
||||
test('isModelExcluded collapses all three excluded variants', () => {
|
||||
expect(isModelExcluded(['gpt-5-mini'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['gpt-5-mini', 'gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
expect(isModelExcluded(['claude-opus'], 'gpt-5-mini')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchedModelsByRule', () => {
|
||||
test('reports what each rule actually catches, in catalog order', () => {
|
||||
expect(matchedModelsByRule(['gpt-5-*'], CATALOG)).toEqual([
|
||||
{ rule: 'gpt-5-*', matched: ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro'], matchCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a rule matching nothing is reported with a zero count, not omitted', () => {
|
||||
expect(matchedModelsByRule(['retired-*'], CATALOG)).toEqual([
|
||||
{ rule: 'retired-*', matched: [], matchCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('an exact rule matches exactly its own model', () => {
|
||||
expect(matchedModelsByRule(['claude-opus'], CATALOG)).toEqual([
|
||||
{ rule: 'claude-opus', matched: ['claude-opus'], matchCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('overlapping rules each report the full set they catch', () => {
|
||||
expect(matchedModelsByRule(['gpt-*', 'gpt-5-pro'], CATALOG)).toEqual([
|
||||
{ rule: 'gpt-*', matched: ['gpt-5-codex', 'gpt-5-mini', 'gpt-5-pro'], matchCount: 3 },
|
||||
{ rule: 'gpt-5-pro', matched: ['gpt-5-pro'], matchCount: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves the given rule order and length', () => {
|
||||
expect(matchedModelsByRule(['b-*', 'a-*'], CATALOG).map((s) => s.rule)).toEqual(['b-*', 'a-*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeExclusion', () => {
|
||||
test('counts catalog models hit by any rule, not the rules themselves', () => {
|
||||
// One rule, three models — a rule count would say 1 and the meter would lie.
|
||||
expect(summarizeExclusion(['gpt-5-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 3,
|
||||
available: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('a model caught by both an exact and a wildcard rule counts once', () => {
|
||||
expect(summarizeExclusion(['gpt-5-mini', 'gpt-5-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 3,
|
||||
available: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('rules that match nothing in the catalog do not inflate the count', () => {
|
||||
expect(summarizeExclusion(['retired-model', 'gone-*'], CATALOG)).toEqual({
|
||||
total: 5,
|
||||
excluded: 0,
|
||||
available: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('available is always total minus excluded', () => {
|
||||
const stats = summarizeExclusion(['gpt-*', 'claude-opus'], CATALOG);
|
||||
expect(stats.available).toBe(stats.total - stats.excluded);
|
||||
expect(stats).toEqual({ total: 5, excluded: 4, available: 1 });
|
||||
});
|
||||
|
||||
test('an empty catalog yields all zeroes rather than NaN', () => {
|
||||
expect(summarizeExclusion(['gpt-5-*'], [])).toEqual({ total: 0, excluded: 0, available: 0 });
|
||||
});
|
||||
|
||||
test('no rules means nothing excluded', () => {
|
||||
expect(summarizeExclusion([], CATALOG)).toEqual({ total: 5, excluded: 0, available: 5 });
|
||||
});
|
||||
});
|
||||
210
frontend/tests/excludedModelRules.test.ts
Normal file
210
frontend/tests/excludedModelRules.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
formatExcludedRulesText,
|
||||
hasExcludedRule,
|
||||
isMatchedByWildcardRule,
|
||||
isWildcardRule,
|
||||
matchesExcludedRule,
|
||||
normalizeExcludedRules,
|
||||
parseExcludedRulesText,
|
||||
replaceCustomExcludedRules,
|
||||
splitExcludedRules,
|
||||
toggleExcludedRule,
|
||||
} from '../src/components/excludedModels/excludedModelRules';
|
||||
|
||||
describe('normalizeExcludedRules / parseExcludedRulesText', () => {
|
||||
test('trims, drops blanks, and removes case-insensitive duplicates', () => {
|
||||
expect(normalizeExcludedRules([' gpt-* ', 'GPT-*', '', 'claude-3'])).toEqual([
|
||||
'gpt-*',
|
||||
'claude-3',
|
||||
]);
|
||||
});
|
||||
|
||||
test('keeps the first spelling of a case-insensitive duplicate', () => {
|
||||
expect(normalizeExcludedRules(['GPT-4o', 'gpt-4O'])).toEqual(['GPT-4o']);
|
||||
});
|
||||
|
||||
test('parsing text is the same operation as normalizing its lines', () => {
|
||||
const text = ' GPT-5-*\ngpt-5-*\nclaude-opus ';
|
||||
expect(parseExcludedRulesText(text)).toEqual(['GPT-5-*', 'claude-opus']);
|
||||
expect(parseExcludedRulesText(text)).toEqual(normalizeExcludedRules(text.split(/\r?\n/)));
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () => {
|
||||
expect(parseExcludedRulesText('a-*\r\nb-model\r\n')).toEqual(['a-*', 'b-model']);
|
||||
});
|
||||
|
||||
test('round-trips through formatExcludedRulesText', () => {
|
||||
const rules = ['GPT-5-*', 'claude-opus'];
|
||||
expect(parseExcludedRulesText(formatExcludedRulesText(rules))).toEqual(rules);
|
||||
});
|
||||
|
||||
/**
|
||||
* 凭证编辑器把 excluded_models 存成换行文本,保存时用 `JSON.stringify` 做**顺序敏感**的
|
||||
* diff(useAuthFilesPrefixProxyEditor.ts:327)。picker 只要在读写之间保持顺序不变,
|
||||
* 「打开但不修改就保存」就永远不会写出与原文件不同的内容。
|
||||
*/
|
||||
test('parse→format is a fixed point for already-normalized input (order preserved)', () => {
|
||||
const fromBackend = ['GPT-5-Codex', 'gpt-5-*', 'retired-model'];
|
||||
const text = fromBackend.join('\n');
|
||||
|
||||
expect(formatExcludedRulesText(parseExcludedRulesText(text))).toBe(text);
|
||||
// 再跑一轮仍是同一个不动点。
|
||||
expect(parseExcludedRulesText(formatExcludedRulesText(parseExcludedRulesText(text)))).toEqual(
|
||||
fromBackend
|
||||
);
|
||||
});
|
||||
|
||||
test('normalization never reorders surviving rules', () => {
|
||||
expect(normalizeExcludedRules(['z-model', 'a-model', 'm-*'])).toEqual([
|
||||
'z-model',
|
||||
'a-model',
|
||||
'm-*',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesExcludedRule', () => {
|
||||
test('matches backend wildcard semantics case-insensitively', () => {
|
||||
expect(matchesExcludedRule('gpt-5-*', 'GPT-5-Codex')).toBe(true);
|
||||
expect(matchesExcludedRule('*-preview', 'gemini-3-pro-preview')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-5-*', 'gpt-4.1')).toBe(false);
|
||||
});
|
||||
|
||||
test('treats regex metacharacters in the rule as literals', () => {
|
||||
// The `.` must be a literal dot, not "any character".
|
||||
expect(matchesExcludedRule('gpt-4.1', 'gpt-4.1')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-4.1', 'gpt-4x1')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-4.*', 'gpt-4.1-mini')).toBe(true);
|
||||
expect(matchesExcludedRule('gpt-4.*', 'gpt-4x1-mini')).toBe(false);
|
||||
});
|
||||
|
||||
test('anchors at both ends', () => {
|
||||
expect(matchesExcludedRule('gpt-5', 'gpt-5-codex')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-5*', 'gpt-5-codex')).toBe(true);
|
||||
});
|
||||
|
||||
test('a blank rule or model never matches', () => {
|
||||
expect(matchesExcludedRule('', 'gpt-5')).toBe(false);
|
||||
expect(matchesExcludedRule('gpt-5', ' ')).toBe(false);
|
||||
});
|
||||
|
||||
test('isWildcardRule / isMatchedByWildcardRule ignore exact rules', () => {
|
||||
expect(isWildcardRule('gpt-5-*')).toBe(true);
|
||||
expect(isWildcardRule('gpt-5-codex')).toBe(false);
|
||||
expect(isMatchedByWildcardRule(['gpt-5-*'], 'gpt-5-mini')).toBe(true);
|
||||
// An exact rule matching the model is not a *wildcard* match.
|
||||
expect(isMatchedByWildcardRule(['gpt-5-mini'], 'gpt-5-mini')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasExcludedRule', () => {
|
||||
test('compares literally and case-insensitively, without wildcard expansion', () => {
|
||||
expect(hasExcludedRule(['GPT-4o'], 'gpt-4O')).toBe(true);
|
||||
expect(hasExcludedRule(['gpt-5-*'], 'gpt-5-codex')).toBe(false);
|
||||
expect(hasExcludedRule(['gpt-5-*'], 'GPT-5-*')).toBe(true);
|
||||
expect(hasExcludedRule(['gpt-4o'], ' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleExcludedRule', () => {
|
||||
test('adds and removes exact rules without touching wildcard rules', () => {
|
||||
const added = toggleExcludedRule(['gpt-5-*'], 'claude-opus', true);
|
||||
expect(added).toEqual(['gpt-5-*', 'claude-opus']);
|
||||
expect(toggleExcludedRule(added, 'CLAUDE-OPUS', false)).toEqual(['gpt-5-*']);
|
||||
});
|
||||
|
||||
test('removes a wildcard rule by name (the old auth-file helper refused to)', () => {
|
||||
expect(toggleExcludedRule(['gpt-5-*', 'claude-opus'], 'GPT-5-*', false)).toEqual([
|
||||
'claude-opus',
|
||||
]);
|
||||
});
|
||||
|
||||
test('adding an existing rule moves it to the end rather than duplicating', () => {
|
||||
expect(toggleExcludedRule(['a', 'b'], 'A', true)).toEqual(['b', 'A']);
|
||||
});
|
||||
|
||||
test('a blank candidate is a no-op add', () => {
|
||||
expect(toggleExcludedRule(['a'], ' ', true)).toEqual(['a']);
|
||||
});
|
||||
|
||||
test('trims the candidate when removing, down to an empty list', () => {
|
||||
expect(toggleExcludedRule(['GPT-4o'], ' gpt-4O ', false)).toEqual([]);
|
||||
});
|
||||
|
||||
test('trims the candidate when adding', () => {
|
||||
expect(toggleExcludedRule(['gpt-4o'], ' gpt-* ', true)).toEqual(['gpt-4o', 'gpt-*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitExcludedRules', () => {
|
||||
test('partitions into exact / wildcard / unknown', () => {
|
||||
expect(
|
||||
splitExcludedRules(
|
||||
['GPT-5-Codex', 'gpt-5-*', 'unlisted-model'],
|
||||
['gpt-5-codex', 'claude-opus']
|
||||
)
|
||||
).toEqual({
|
||||
exactRules: ['gpt-5-codex'],
|
||||
wildcardRules: ['gpt-5-*'],
|
||||
unknownRules: ['unlisted-model'],
|
||||
customRules: ['gpt-5-*', 'unlisted-model'],
|
||||
});
|
||||
});
|
||||
|
||||
test('rewrites exact rules to the catalog spelling', () => {
|
||||
expect(splitExcludedRules(['GPT-5-CODEX'], ['gpt-5-codex']).exactRules).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('preserves the configured spelling for wildcard and unknown rules', () => {
|
||||
const { wildcardRules, unknownRules } = splitExcludedRules(
|
||||
['GPT-5-*', 'Retired-Model'],
|
||||
['gpt-5-codex']
|
||||
);
|
||||
expect(wildcardRules).toEqual(['GPT-5-*']);
|
||||
expect(unknownRules).toEqual(['Retired-Model']);
|
||||
});
|
||||
|
||||
test('customRules keeps the original interleaved order, not bucket order', () => {
|
||||
// `unlisted` appears before `a-*`; concatenating the buckets would reverse them.
|
||||
expect(splitExcludedRules(['unlisted', 'a-*'], ['gpt-5-codex']).customRules).toEqual([
|
||||
'unlisted',
|
||||
'a-*',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an empty catalog makes every exact rule unknown', () => {
|
||||
expect(splitExcludedRules(['a', 'b-*'], [])).toEqual({
|
||||
exactRules: [],
|
||||
wildcardRules: ['b-*'],
|
||||
unknownRules: ['a'],
|
||||
customRules: ['a', 'b-*'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceCustomExcludedRules', () => {
|
||||
test('swaps the custom half while retaining exact selections', () => {
|
||||
expect(
|
||||
replaceCustomExcludedRules(
|
||||
['gpt-5-codex', 'old-*'],
|
||||
['gpt-5-codex', 'claude-opus'],
|
||||
'new-*\nlegacy-model'
|
||||
)
|
||||
).toEqual(['gpt-5-codex', 'new-*', 'legacy-model']);
|
||||
});
|
||||
|
||||
test('clearing the text leaves only the exact selections', () => {
|
||||
expect(replaceCustomExcludedRules(['gpt-5-codex', 'old-*'], ['gpt-5-codex'], '')).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a custom rule duplicating an exact selection does not double it', () => {
|
||||
expect(replaceCustomExcludedRules(['gpt-5-codex'], ['gpt-5-codex'], 'GPT-5-CODEX')).toEqual([
|
||||
'gpt-5-codex',
|
||||
]);
|
||||
});
|
||||
});
|
||||
26
frontend/tests/fennoProvider.test.ts
Normal file
26
frontend/tests/fennoProvider.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildFennoAIRaw,
|
||||
FENNO_AI_CODEX_BASE_URL,
|
||||
FENNO_AI_PROVIDER_NAME,
|
||||
} from '../src/features/providers/fennoAI';
|
||||
import { getSponsorProviderDefinition } from '../src/features/providers/sponsorDefinitions';
|
||||
|
||||
describe('FennoAI provider aggregation', () => {
|
||||
test('does not claim OpenAI configs that its form cannot display', () => {
|
||||
const raw = buildFennoAIRaw({
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name: FENNO_AI_PROVIDER_NAME,
|
||||
baseUrl: FENNO_AI_CODEX_BASE_URL,
|
||||
apiKeyEntries: [{ apiKey: 'openai-key' }],
|
||||
},
|
||||
],
|
||||
codexApiKeys: [{ apiKey: 'codex-key', baseUrl: FENNO_AI_CODEX_BASE_URL }],
|
||||
});
|
||||
|
||||
expect(getSponsorProviderDefinition('fennoAI').protocols).toEqual(['codex', 'claude']);
|
||||
expect(raw.openai).toEqual([]);
|
||||
expect(raw.codex.map((item) => item.index)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
102
frontend/tests/infistarProvider.test.ts
Normal file
102
frontend/tests/infistarProvider.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { infistarToResource } from '../src/features/providers/adapters';
|
||||
import { PROVIDER_LOGOS } from '../src/features/providers/brandLogos';
|
||||
import { PROVIDER_BRAND_ORDER } from '../src/features/providers/descriptors';
|
||||
import {
|
||||
INFISTAR_AFFILIATE_URL,
|
||||
INFISTAR_BASE_URL_OPTIONS,
|
||||
INFISTAR_DOMESTIC_BASE_URL,
|
||||
INFISTAR_DOMESTIC_ROOT_URL,
|
||||
INFISTAR_GLOBAL_BASE_URL,
|
||||
INFISTAR_GLOBAL_ROOT_URL,
|
||||
buildInfistarRaw,
|
||||
getInfistarProtocolUrls,
|
||||
resolveInfistarBaseUrl,
|
||||
} from '../src/features/providers/infistar';
|
||||
import { getSponsorProviderDefinition } from '../src/features/providers/sponsorDefinitions';
|
||||
|
||||
const allProtocolConfig = {
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name: 'infistar',
|
||||
baseUrl: INFISTAR_DOMESTIC_BASE_URL,
|
||||
apiKeyEntries: [{ apiKey: 'openai-key' }],
|
||||
},
|
||||
],
|
||||
claudeApiKeys: [{ apiKey: 'claude-key', baseUrl: INFISTAR_DOMESTIC_ROOT_URL }],
|
||||
codexApiKeys: [{ apiKey: 'codex-key', baseUrl: INFISTAR_DOMESTIC_BASE_URL }],
|
||||
geminiApiKeys: [{ apiKey: 'gemini-key', baseUrl: INFISTAR_DOMESTIC_ROOT_URL }],
|
||||
interactionsApiKeys: [{ apiKey: 'interactions-key', baseUrl: INFISTAR_DOMESTIC_BASE_URL }],
|
||||
};
|
||||
|
||||
describe('Infistar sponsor provider', () => {
|
||||
test('offers the requested mainland China and global URLs', () => {
|
||||
expect(INFISTAR_AFFILIATE_URL).toBe(
|
||||
'https://infistar.ai/register?aff=FQKC6J6R&ref_source=link'
|
||||
);
|
||||
expect(
|
||||
INFISTAR_BASE_URL_OPTIONS.map(({ id, baseUrl }) => ({
|
||||
id,
|
||||
baseUrl,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'mainlandChina', baseUrl: 'https://coneverse.com/v1' },
|
||||
{ id: 'global', baseUrl: 'https://infistar.ai/v1' },
|
||||
]);
|
||||
expect(resolveInfistarBaseUrl(undefined)).toBe(INFISTAR_DOMESTIC_BASE_URL);
|
||||
expect(resolveInfistarBaseUrl(INFISTAR_GLOBAL_ROOT_URL)).toBe(INFISTAR_GLOBAL_BASE_URL);
|
||||
});
|
||||
|
||||
test('maps both choices to all four supported protocol endpoints', () => {
|
||||
expect(getInfistarProtocolUrls(undefined)).toEqual({
|
||||
openai: 'https://coneverse.com/v1',
|
||||
codex: 'https://coneverse.com/v1',
|
||||
anthropic: 'https://coneverse.com',
|
||||
gemini: 'https://coneverse.com',
|
||||
});
|
||||
expect(getInfistarProtocolUrls(INFISTAR_GLOBAL_BASE_URL)).toEqual({
|
||||
openai: 'https://infistar.ai/v1',
|
||||
codex: 'https://infistar.ai/v1',
|
||||
anthropic: 'https://infistar.ai',
|
||||
gemini: 'https://infistar.ai',
|
||||
});
|
||||
|
||||
const definition = getSponsorProviderDefinition('infistar');
|
||||
expect(definition.protocols).toEqual(['openai', 'claude', 'gemini', 'codex']);
|
||||
expect(definition.protocols).not.toContain('interactions');
|
||||
});
|
||||
|
||||
test('aggregates four protocol configs without claiming Interactions API', () => {
|
||||
const raw = buildInfistarRaw(allProtocolConfig);
|
||||
|
||||
expect(raw.openai.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.claude.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.codex.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.gemini.map((item) => item.index)).toEqual([0]);
|
||||
|
||||
const resource = infistarToResource(raw);
|
||||
expect(resource?.brand).toBe('infistar');
|
||||
expect(resource?.name).toBe('无限星河');
|
||||
expect(resource?.flags.protocols).toEqual(['openai', 'anthropic', 'gemini', 'codexResponses']);
|
||||
});
|
||||
|
||||
test('keeps custom endpoints outside the Infistar sponsor group', () => {
|
||||
const raw = buildInfistarRaw({
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name: 'infistar',
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
apiKeyEntries: [{ apiKey: 'custom-key' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(raw.openai).toEqual([]);
|
||||
});
|
||||
|
||||
test('is appended to the provider catalog with the supplied logo', () => {
|
||||
expect(PROVIDER_BRAND_ORDER.at(-1)).toBe('infistar');
|
||||
expect(PROVIDER_LOGOS.infistar.src).toContain('infistar.png');
|
||||
expect(PROVIDER_LOGOS.infistar.transparent).toBe(true);
|
||||
});
|
||||
});
|
||||
249
frontend/tests/interactionsApiProvider.test.ts
Normal file
249
frontend/tests/interactionsApiProvider.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildInteractionsEndpoint,
|
||||
buildInteractionsProbePayload,
|
||||
getProviderUsageKey,
|
||||
INTERACTIONS_API_REVISION,
|
||||
} from '../src/components/providers/utils';
|
||||
import { interactionsToResource } from '../src/features/providers/adapters';
|
||||
import { PROVIDER_BRAND_ORDER, PROVIDER_DESCRIPTORS } from '../src/features/providers/descriptors';
|
||||
import { MODEL_DISCOVERY_BRANDS } from '../src/features/providers/sheets/forms/useModelDiscovery';
|
||||
import { apiClient } from '../src/services/api/client';
|
||||
import { providersApi } from '../src/services/api/providers';
|
||||
import { normalizeConfigResponse } from '../src/services/api/transformers';
|
||||
|
||||
const originalGet = apiClient.get;
|
||||
const originalPut = apiClient.put;
|
||||
const originalDelete = apiClient.delete;
|
||||
|
||||
afterEach(() => {
|
||||
apiClient.get = originalGet;
|
||||
apiClient.put = originalPut;
|
||||
apiClient.delete = originalDelete;
|
||||
});
|
||||
|
||||
describe('Interactions API key provider', () => {
|
||||
test('normalizes the backend contract and exposes a dedicated workbench resource', () => {
|
||||
const config = normalizeConfigResponse({
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'interactions-secret',
|
||||
priority: 8,
|
||||
weight: 3,
|
||||
prefix: 'native',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'proxy-url': 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3.1-flash-lite',
|
||||
alias: 'native-flash',
|
||||
thinking: { levels: ['low', 'medium', 'high'] },
|
||||
},
|
||||
],
|
||||
'excluded-models': ['gemini-2.5-*'],
|
||||
'disable-cooling': true,
|
||||
'auth-index': 'gemini-interactions:apikey:1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.interactionsApiKeys).toEqual([
|
||||
{
|
||||
apiKey: 'interactions-secret',
|
||||
priority: 8,
|
||||
weight: 3,
|
||||
prefix: 'native',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
proxyUrl: 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3.1-flash-lite',
|
||||
alias: 'native-flash',
|
||||
thinking: { levels: ['low', 'medium', 'high'] },
|
||||
},
|
||||
],
|
||||
excludedModels: ['gemini-2.5-*'],
|
||||
disableCooling: true,
|
||||
authIndex: 'gemini-interactions:apikey:1',
|
||||
},
|
||||
]);
|
||||
|
||||
const resource = interactionsToResource(config.interactionsApiKeys![0], 0);
|
||||
expect(resource.brand).toBe('interactions');
|
||||
expect(resource.models).toEqual(['gemini-3.1-flash-lite']);
|
||||
expect(resource.selector).toEqual({
|
||||
brand: 'interactions',
|
||||
apiKey: 'interactions-secret',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
index: 0,
|
||||
});
|
||||
expect(PROVIDER_DESCRIPTORS.interactions.baseUrlRequired).toBe(false);
|
||||
expect(PROVIDER_DESCRIPTORS.interactions.supportsTestModel).toBe(true);
|
||||
expect(PROVIDER_BRAND_ORDER.indexOf('interactions')).toBe(
|
||||
PROVIDER_BRAND_ORDER.indexOf('gemini') + 1
|
||||
);
|
||||
expect(MODEL_DISCOVERY_BRANDS).toContain('interactions');
|
||||
});
|
||||
|
||||
test('builds the native interactions endpoint from supported base URL forms', () => {
|
||||
expect(buildInteractionsEndpoint('')).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://generativelanguage.googleapis.com')).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://example.com/v1beta')).toBe(
|
||||
'https://example.com/v1beta/interactions'
|
||||
);
|
||||
expect(buildInteractionsEndpoint('https://example.com/v1beta/interactions')).toBe(
|
||||
'https://example.com/v1beta/interactions'
|
||||
);
|
||||
});
|
||||
|
||||
test('uses the documented revision and minimal non-streaming probe body', () => {
|
||||
expect(INTERACTIONS_API_REVISION).toBe('2026-05-20');
|
||||
expect(buildInteractionsProbePayload('gemini-3.6-flash')).toEqual({
|
||||
model: 'gemini-3.6-flash',
|
||||
input: 'Hi',
|
||||
});
|
||||
});
|
||||
|
||||
test('maps the UI brand to the backend runtime usage provider', () => {
|
||||
expect(getProviderUsageKey('interactions')).toBe('gemini-interactions');
|
||||
expect(getProviderUsageKey('gemini')).toBe('gemini');
|
||||
expect(getProviderUsageKey('claudeApi')).toBe('claude');
|
||||
});
|
||||
|
||||
test('updates only the matching key and base URL while preserving unknown fields', async () => {
|
||||
let putData: unknown;
|
||||
apiClient.get = (async () => ({
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://first.example.com',
|
||||
'future-field': 'first',
|
||||
},
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://second.example.com',
|
||||
'proxy-url': 'direct',
|
||||
headers: { 'X-Old': 'value' },
|
||||
'excluded-models': ['old-model'],
|
||||
'disable-cooling': true,
|
||||
'future-field': 'preserved',
|
||||
'auth-index': 'response-only',
|
||||
},
|
||||
],
|
||||
})) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
putData = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.updateInteractionsKey('shared-key', 'https://second.example.com', {
|
||||
apiKey: 'shared-key',
|
||||
baseUrl: 'https://updated.example.com',
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
});
|
||||
|
||||
expect(putData).toEqual([
|
||||
{
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://first.example.com',
|
||||
'future-field': 'first',
|
||||
},
|
||||
{
|
||||
'future-field': 'preserved',
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://updated.example.com',
|
||||
models: [{ name: 'gemini-3.1-flash-lite', alias: 'native-flash' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('creates and deletes keys through the interactions management endpoints', async () => {
|
||||
const calls: Array<{ method: string; url: string; data?: unknown }> = [];
|
||||
apiClient.get = (async (url: string) => {
|
||||
calls.push({ method: 'GET', url });
|
||||
return {
|
||||
'interactions-api-key': [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
],
|
||||
};
|
||||
}) as typeof apiClient.get;
|
||||
apiClient.put = (async (url: string, data?: unknown) => {
|
||||
calls.push({ method: 'PUT', url, data });
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
apiClient.delete = (async (url: string) => {
|
||||
calls.push({ method: 'DELETE', url });
|
||||
return undefined;
|
||||
}) as typeof apiClient.delete;
|
||||
|
||||
await providersApi.createInteractionsKey({
|
||||
apiKey: 'interactions-new',
|
||||
priority: 4,
|
||||
weight: 2,
|
||||
prefix: 'native',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
proxyUrl: 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3.1-flash-lite',
|
||||
alias: 'native-flash',
|
||||
thinking: { min: 128, max: 8192, dynamic_allowed: true },
|
||||
},
|
||||
],
|
||||
excludedModels: ['gemini-2.5-*'],
|
||||
disableCooling: true,
|
||||
});
|
||||
await providersApi.deleteInteractionsKey(
|
||||
'interactions-new',
|
||||
'https://generativelanguage.googleapis.com'
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: '/config' },
|
||||
{
|
||||
method: 'PUT',
|
||||
url: '/interactions-api-key',
|
||||
data: [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
{
|
||||
'api-key': 'interactions-new',
|
||||
priority: 4,
|
||||
weight: 2,
|
||||
prefix: 'native',
|
||||
'base-url': 'https://generativelanguage.googleapis.com',
|
||||
'proxy-url': 'direct',
|
||||
'disable-cooling': true,
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3.1-flash-lite',
|
||||
alias: 'native-flash',
|
||||
thinking: { min: 128, max: 8192, dynamic_allowed: true },
|
||||
},
|
||||
],
|
||||
'excluded-models': ['gemini-2.5-*'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: '/interactions-api-key?api-key=interactions-new&base-url=https%3A%2F%2Fgenerativelanguage.googleapis.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
152
frontend/tests/kimiProvider.test.ts
Normal file
152
frontend/tests/kimiProvider.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { buildOpenAIChatCompletionsEndpoint } from '../src/components/providers/utils';
|
||||
import {
|
||||
KIMI_ANTHROPIC_BASE_URL,
|
||||
KIMI_CHINESE_AFFILIATE_URL,
|
||||
KIMI_DOMESTIC_ANTHROPIC_BASE_URL,
|
||||
KIMI_DOMESTIC_BASE_URL,
|
||||
KIMI_DOMESTIC_OPENAI_BASE_URL,
|
||||
KIMI_INTERNATIONAL_AFFILIATE_URL,
|
||||
KIMI_LEGACY_OPENAI_BASE_URL,
|
||||
KIMI_OPENAI_BASE_URL,
|
||||
buildKimiRaw,
|
||||
getKimiAffiliateUrl,
|
||||
getKimiProtocolUrls,
|
||||
isKimiClaudeProvider,
|
||||
isKimiOpenAIProvider,
|
||||
resolveKimiBaseUrl,
|
||||
} from '../src/features/providers/kimi';
|
||||
import { PROVIDER_LOGOS } from '../src/features/providers/brandLogos';
|
||||
import { PROVIDER_BRAND_ORDER } from '../src/features/providers/descriptors';
|
||||
import { getSponsorProviderDefinition } from '../src/features/providers/sponsorDefinitions';
|
||||
import { apiCallApi } from '../src/services/api/apiCall';
|
||||
import { modelsApi } from '../src/services/api/models';
|
||||
|
||||
const originalApiCallRequest = apiCallApi.request;
|
||||
|
||||
afterEach(() => {
|
||||
apiCallApi.request = originalApiCallRequest;
|
||||
});
|
||||
|
||||
describe('Kimi provider', () => {
|
||||
test('defaults to the domestic OpenAI-compatible and Claude protocol endpoints', () => {
|
||||
expect(getKimiProtocolUrls(undefined)).toEqual({
|
||||
openai: 'https://api.moonshot.cn/v1',
|
||||
anthropic: 'https://api.moonshot.cn/anthropic',
|
||||
codex: '',
|
||||
gemini: '',
|
||||
});
|
||||
expect(getKimiProtocolUrls(KIMI_OPENAI_BASE_URL)).toEqual({
|
||||
openai: 'https://api.moonshot.ai/v1',
|
||||
anthropic: 'https://api.moonshot.ai/anthropic',
|
||||
codex: '',
|
||||
gemini: '',
|
||||
});
|
||||
expect(buildOpenAIChatCompletionsEndpoint(KIMI_OPENAI_BASE_URL)).toBe(
|
||||
'https://api.moonshot.ai/v1/chat/completions'
|
||||
);
|
||||
expect(getSponsorProviderDefinition('kimi').protocols).toEqual(['openai', 'claude']);
|
||||
});
|
||||
|
||||
test('offers overseas and domestic URLs and maps the domestic protocol endpoints', () => {
|
||||
expect(
|
||||
getSponsorProviderDefinition('kimi').baseUrlOptions.map(({ id, baseUrl }) => ({
|
||||
id,
|
||||
baseUrl,
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'domestic', baseUrl: KIMI_DOMESTIC_OPENAI_BASE_URL },
|
||||
{ id: 'overseas', baseUrl: KIMI_OPENAI_BASE_URL },
|
||||
]);
|
||||
expect(resolveKimiBaseUrl(undefined)).toBe(KIMI_DOMESTIC_OPENAI_BASE_URL);
|
||||
expect(resolveKimiBaseUrl(KIMI_DOMESTIC_BASE_URL)).toBe(KIMI_DOMESTIC_OPENAI_BASE_URL);
|
||||
expect(resolveKimiBaseUrl(KIMI_LEGACY_OPENAI_BASE_URL)).toBe(KIMI_OPENAI_BASE_URL);
|
||||
expect(resolveKimiBaseUrl(KIMI_DOMESTIC_ANTHROPIC_BASE_URL)).toBe(
|
||||
KIMI_DOMESTIC_OPENAI_BASE_URL
|
||||
);
|
||||
expect(getKimiProtocolUrls(KIMI_DOMESTIC_OPENAI_BASE_URL)).toEqual({
|
||||
openai: 'https://api.moonshot.cn/v1',
|
||||
anthropic: 'https://api.moonshot.cn/anthropic',
|
||||
codex: '',
|
||||
gemini: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('discovers models through the versioned OpenAI endpoint', async () => {
|
||||
let requestedUrl = '';
|
||||
apiCallApi.request = (async (payload) => {
|
||||
requestedUrl = payload.url;
|
||||
return { statusCode: 200, header: {}, bodyText: '', body: { data: [] } };
|
||||
}) as typeof apiCallApi.request;
|
||||
|
||||
await modelsApi.fetchModelsViaApiCall(KIMI_OPENAI_BASE_URL, 'test-key');
|
||||
|
||||
expect(requestedUrl).toBe('https://api.moonshot.ai/v1/models');
|
||||
});
|
||||
|
||||
test('uses the domestic registration link for Chinese and the international link otherwise', () => {
|
||||
expect(getKimiAffiliateUrl('zh-CN')).toBe(KIMI_CHINESE_AFFILIATE_URL);
|
||||
expect(getKimiAffiliateUrl('zh-TW')).toBe(KIMI_CHINESE_AFFILIATE_URL);
|
||||
expect(getKimiAffiliateUrl('en')).toBe(KIMI_INTERNATIONAL_AFFILIATE_URL);
|
||||
expect(getKimiAffiliateUrl('ru')).toBe(KIMI_INTERNATIONAL_AFFILIATE_URL);
|
||||
});
|
||||
|
||||
test('uses the OAuth-style theme surface for its provider icon', () => {
|
||||
expect(PROVIDER_LOGOS.kimi.themeSurface).toBe(true);
|
||||
});
|
||||
|
||||
test('is the first provider in the catalog', () => {
|
||||
expect(PROVIDER_BRAND_ORDER[0]).toBe('kimi');
|
||||
});
|
||||
|
||||
test('recognizes Kimi configs only by supported protocol endpoint', () => {
|
||||
expect(
|
||||
isKimiOpenAIProvider({
|
||||
name: 'Kimi',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
})
|
||||
).toBe(false);
|
||||
expect(
|
||||
isKimiOpenAIProvider({
|
||||
name: 'moonshot',
|
||||
baseUrl: `${KIMI_OPENAI_BASE_URL}/`,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isKimiOpenAIProvider({
|
||||
name: 'legacy-moonshot',
|
||||
baseUrl: KIMI_LEGACY_OPENAI_BASE_URL,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isKimiOpenAIProvider({
|
||||
name: 'domestic-moonshot',
|
||||
baseUrl: KIMI_DOMESTIC_OPENAI_BASE_URL,
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
isKimiClaudeProvider({ apiKey: 'sk-test', baseUrl: KIMI_ANTHROPIC_BASE_URL })
|
||||
).toBe(true);
|
||||
expect(
|
||||
isKimiClaudeProvider({ apiKey: 'sk-test', baseUrl: KIMI_DOMESTIC_ANTHROPIC_BASE_URL })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('aggregates only the Kimi OpenAI-compatible and Claude configs', () => {
|
||||
const raw = buildKimiRaw({
|
||||
openaiCompatibility: [
|
||||
{ name: 'kimi', baseUrl: KIMI_OPENAI_BASE_URL },
|
||||
{ name: 'other', baseUrl: 'https://example.com' },
|
||||
],
|
||||
claudeApiKeys: [
|
||||
{ apiKey: 'kimi-key', baseUrl: KIMI_ANTHROPIC_BASE_URL },
|
||||
{ apiKey: 'other-key', baseUrl: 'https://api.anthropic.com' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(raw.openai.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.claude.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.codex).toEqual([]);
|
||||
expect(raw.gemini).toEqual([]);
|
||||
});
|
||||
});
|
||||
74
frontend/tests/kimiQuotaOrder.test.ts
Normal file
74
frontend/tests/kimiQuotaOrder.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { buildTimelineLane } from '@/features/quota/quotaTimelineModel';
|
||||
import { buildKimiQuotaRows } from '@/utils/quota';
|
||||
|
||||
describe('Kimi quota ordering', () => {
|
||||
test('shows the 5-hour limit before the weekly limit and exposes it to the timeline', () => {
|
||||
const rows = buildKimiQuotaRows({
|
||||
usage: {
|
||||
used: '1',
|
||||
limit: '100',
|
||||
remaining: '99',
|
||||
resetTime: '2099-08-06T13:59:23.136523Z',
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
detail: {
|
||||
used: '2',
|
||||
limit: '100',
|
||||
remaining: '98',
|
||||
resetTime: '2099-07-31T06:59:23.136523Z',
|
||||
},
|
||||
window: {
|
||||
duration: 300,
|
||||
timeUnit: 'TIME_UNIT_MINUTE',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rows.map(({ id }) => id)).toEqual(['limit-0', 'summary']);
|
||||
expect(rows[0]?.labelKey).toBe('kimi_quota.limit_window');
|
||||
expect(rows[0]?.labelParams).toEqual({ duration: '5h' });
|
||||
expect(rows[0]?.periodHours).toBe(5);
|
||||
expect(rows[1]?.labelKey).toBe('kimi_quota.weekly_limit');
|
||||
expect(rows[1]?.periodHours).toBe(168);
|
||||
|
||||
const lane = buildTimelineLane({
|
||||
name: 'kimi.json',
|
||||
displayName: 'Kimi',
|
||||
provider: 'kimi',
|
||||
quota: { status: 'success', rows },
|
||||
maxPeriodHours: 5,
|
||||
});
|
||||
expect(lane.anchorMs).toBe(rows[0]?.resetAtMs);
|
||||
expect(lane.periodHours).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kimi quota reset formatting', () => {
|
||||
const getResetHint = (resetIn: number): string | undefined => {
|
||||
const rows = buildKimiQuotaRows({
|
||||
usage: {
|
||||
used: 200,
|
||||
limit: 1000,
|
||||
resetIn,
|
||||
},
|
||||
});
|
||||
|
||||
return rows[0]?.resetHint;
|
||||
};
|
||||
|
||||
test('converts reset durations longer than a day to days and hours', () => {
|
||||
expect(getResetHint(132 * 3600)).toBe('5d 12h');
|
||||
expect(getResetHint(168 * 3600)).toBe('7d 0h');
|
||||
});
|
||||
|
||||
test('keeps hour and minute formatting for durations shorter than a day', () => {
|
||||
expect(getResetHint(5 * 3600 + 30 * 60)).toBe('5h 30m');
|
||||
});
|
||||
|
||||
test('shows less than one minute for short positive durations', () => {
|
||||
expect(getResetHint(59)).toBe('<1m');
|
||||
});
|
||||
});
|
||||
78
frontend/tests/lmuAIProvider.test.ts
Normal file
78
frontend/tests/lmuAIProvider.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { lmuAIToResource } from '../src/features/providers/adapters';
|
||||
import { PROVIDER_LOGOS } from '../src/features/providers/brandLogos';
|
||||
import { PROVIDER_BRAND_ORDER } from '../src/features/providers/descriptors';
|
||||
import {
|
||||
LMU_AI_AFFILIATE_URL,
|
||||
LMU_AI_BASE_URL,
|
||||
LMU_AI_OPENAI_BASE_URL,
|
||||
buildLmuAIRaw,
|
||||
getLmuAIProtocolUrls,
|
||||
} from '../src/features/providers/lmuAI';
|
||||
import { getSponsorProviderDefinition } from '../src/features/providers/sponsorDefinitions';
|
||||
|
||||
const allProtocolConfig = {
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name: 'lmuAI',
|
||||
baseUrl: LMU_AI_OPENAI_BASE_URL,
|
||||
apiKeyEntries: [{ apiKey: 'openai-key' }],
|
||||
},
|
||||
],
|
||||
claudeApiKeys: [{ apiKey: 'claude-key', baseUrl: LMU_AI_BASE_URL }],
|
||||
codexApiKeys: [{ apiKey: 'codex-key', baseUrl: LMU_AI_OPENAI_BASE_URL }],
|
||||
geminiApiKeys: [{ apiKey: 'gemini-key', baseUrl: LMU_AI_BASE_URL }],
|
||||
interactionsApiKeys: [{ apiKey: 'interactions-key', baseUrl: LMU_AI_BASE_URL }],
|
||||
};
|
||||
|
||||
describe('LMU AI provider', () => {
|
||||
test('uses the official URL for all four supported protocols', () => {
|
||||
expect(LMU_AI_AFFILIATE_URL).toBe('https://api.lmuai.com/register?ref=yJ6Kwg9g');
|
||||
expect(getLmuAIProtocolUrls(undefined)).toEqual({
|
||||
openai: 'https://api.lmuai.com/v1',
|
||||
codex: 'https://api.lmuai.com/v1',
|
||||
anthropic: 'https://api.lmuai.com',
|
||||
gemini: 'https://api.lmuai.com',
|
||||
});
|
||||
|
||||
const definition = getSponsorProviderDefinition('lmuAI');
|
||||
expect(definition.protocols).toEqual(['openai', 'claude', 'gemini', 'codex']);
|
||||
expect(definition.protocols).not.toContain('interactions');
|
||||
});
|
||||
|
||||
test('aggregates the four protocol configs without claiming Interactions API', () => {
|
||||
const raw = buildLmuAIRaw(allProtocolConfig);
|
||||
|
||||
expect(raw.openai.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.claude.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.codex.map((item) => item.index)).toEqual([0]);
|
||||
expect(raw.gemini.map((item) => item.index)).toEqual([0]);
|
||||
|
||||
const resource = lmuAIToResource(raw);
|
||||
expect(resource?.brand).toBe('lmuAI');
|
||||
expect(resource?.name).toBe('LMU AI(灵眸AI)');
|
||||
expect(resource?.flags.protocols).toEqual(['openai', 'anthropic', 'gemini', 'codexResponses']);
|
||||
});
|
||||
|
||||
test('keeps custom endpoints outside the LMU AI sponsor group', () => {
|
||||
const raw = buildLmuAIRaw({
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name: 'lmuAI',
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
apiKeyEntries: [{ apiKey: 'custom-key' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(raw.openai).toEqual([]);
|
||||
});
|
||||
|
||||
test('remains in the provider catalog with the sponsor logo', () => {
|
||||
expect(PROVIDER_BRAND_ORDER).toContain('lmuAI');
|
||||
expect(PROVIDER_BRAND_ORDER.indexOf('lmuAI')).toBeLessThan(
|
||||
PROVIDER_BRAND_ORDER.indexOf('infistar')
|
||||
);
|
||||
expect(PROVIDER_LOGOS.lmuAI.src).toContain('lmu-ai.png');
|
||||
});
|
||||
});
|
||||
10
frontend/tests/modelAliasValidation.test.ts
Normal file
10
frontend/tests/modelAliasValidation.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { hasModelAliasConflict } from '../src/components/modelAlias/aliasValidation';
|
||||
|
||||
describe('model alias validation', () => {
|
||||
test('checks aliases case-insensitively while excluding the renamed node', () => {
|
||||
expect(hasModelAliasConflict(['Foo'], ' foo ')).toBe(true);
|
||||
expect(hasModelAliasConflict(['Foo'], 'foo', 'Foo')).toBe(false);
|
||||
expect(hasModelAliasConflict(['Foo', 'Bar'], 'FOO', 'Bar')).toBe(true);
|
||||
});
|
||||
});
|
||||
53
frontend/tests/oauthConfigLoadGuard.test.ts
Normal file
53
frontend/tests/oauthConfigLoadGuard.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import '../src/i18n/index';
|
||||
import { OAuthExcludedCard } from '../src/features/authFiles/components/OAuthExcludedCard';
|
||||
import { OAuthModelAliasCard } from '../src/features/authFiles/components/OAuthModelAliasCard';
|
||||
|
||||
const noop = () => {};
|
||||
const noopAsync = async () => {};
|
||||
|
||||
describe('OAuth configuration load guards', () => {
|
||||
test('disables excluded-model writes and exposes retry after a load failure', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(OAuthExcludedCard, {
|
||||
disableControls: false,
|
||||
excludedError: 'load',
|
||||
excluded: {},
|
||||
onRetry: noop,
|
||||
onAdd: noop,
|
||||
onEdit: noop,
|
||||
onDelete: noop,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('disabled=""');
|
||||
expect(markup).toContain('empty-action');
|
||||
});
|
||||
|
||||
test('disables model-alias writes and exposes retry after a load failure', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(OAuthModelAliasCard, {
|
||||
disableControls: false,
|
||||
viewMode: 'list',
|
||||
onViewModeChange: noop,
|
||||
onRetry: noop,
|
||||
onAdd: noop,
|
||||
onEditProvider: noop,
|
||||
onDeleteProvider: noop,
|
||||
modelAliasError: 'load',
|
||||
modelAlias: {},
|
||||
allProviderModels: {},
|
||||
onUpdate: noopAsync,
|
||||
onDeleteLink: noop,
|
||||
onToggleFork: noopAsync,
|
||||
onRenameAlias: noopAsync,
|
||||
onDeleteAlias: noop,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('disabled=""');
|
||||
expect(markup).toContain('empty-action');
|
||||
});
|
||||
});
|
||||
27
frontend/tests/oauthEditorDirtyState.test.ts
Normal file
27
frontend/tests/oauthEditorDirtyState.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
getModelAliasDraftSignature,
|
||||
getStringSetSignature,
|
||||
isOAuthEditorDirty,
|
||||
} from '../src/features/authFiles/oauthEditorState';
|
||||
|
||||
describe('OAuth editor dirty state', () => {
|
||||
test('compares model selections independent of order', () => {
|
||||
expect(getStringSetSignature(['b', 'a'])).toBe(getStringSetSignature(['a', 'b']));
|
||||
});
|
||||
|
||||
test('ignores generated row ids but preserves partial alias edits', () => {
|
||||
expect(getModelAliasDraftSignature([{ id: 'one', name: '', alias: '', fork: true }])).toBe(
|
||||
getModelAliasDraftSignature([])
|
||||
);
|
||||
expect(
|
||||
getModelAliasDraftSignature([{ id: 'one', name: 'partial', alias: '', fork: true }])
|
||||
).not.toBe(getModelAliasDraftSignature([]));
|
||||
});
|
||||
|
||||
test('marks provider or content changes as dirty', () => {
|
||||
expect(isOAuthEditorDirty('codex', 'codex', 'same', 'same')).toBe(false);
|
||||
expect(isOAuthEditorDirty('codex', 'claude', 'same', 'same')).toBe(true);
|
||||
expect(isOAuthEditorDirty('codex', 'codex', 'before', 'after')).toBe(true);
|
||||
});
|
||||
});
|
||||
27
frontend/tests/oauthForceMapping.test.ts
Normal file
27
frontend/tests/oauthForceMapping.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
normalizeOauthModelAlias,
|
||||
serializeOauthModelAliases,
|
||||
} from '../src/services/api/authFiles';
|
||||
|
||||
describe('OAuth model alias force mapping', () => {
|
||||
test('normalizes and serializes force-mapping without dropping it', () => {
|
||||
const normalized = normalizeOauthModelAlias({
|
||||
'oauth-model-alias': {
|
||||
codex: [
|
||||
{ name: 'gpt-source', alias: 'gpt-alias', 'force-mapping': true },
|
||||
{ name: 'gpt-source-2', alias: 'gpt-alias-2', forceMapping: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.codex).toEqual([
|
||||
{ name: 'gpt-source', alias: 'gpt-alias', forceMapping: true },
|
||||
{ name: 'gpt-source-2', alias: 'gpt-alias-2', forceMapping: false },
|
||||
]);
|
||||
expect(serializeOauthModelAliases(normalized.codex)).toEqual([
|
||||
{ name: 'gpt-source', alias: 'gpt-alias', 'force-mapping': true },
|
||||
{ name: 'gpt-source-2', alias: 'gpt-alias-2', 'force-mapping': false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
93
frontend/tests/pluginConfigDraft.test.ts
Normal file
93
frontend/tests/pluginConfigDraft.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildPluginConfigDraft,
|
||||
buildPluginConfigPatch,
|
||||
} from '../src/features/plugins/pluginConfigDraft';
|
||||
import type { PluginConfigField } from '../src/types';
|
||||
|
||||
const fields: PluginConfigField[] = [
|
||||
{ name: 'mixed', type: 'array', enumValues: [], description: '' },
|
||||
{ name: 'optional', type: 'boolean', enumValues: [], description: '' },
|
||||
{ name: 'label', type: 'string', enumValues: [], description: '' },
|
||||
];
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe('plugin config draft', () => {
|
||||
test('represents arrays as JSON without marking missing booleans as touched', () => {
|
||||
const draft = buildPluginConfigDraft(
|
||||
{ enabled: true, configFields: fields },
|
||||
{ priority: 3, mixed: [1, true, { x: 1 }] }
|
||||
);
|
||||
|
||||
expect(draft.values.mixed).toBe('[\n 1,\n true,\n {\n "x": 1\n }\n]');
|
||||
expect(draft.values.optional).toBe(false);
|
||||
expect(draft.touchedFields).toEqual({});
|
||||
});
|
||||
|
||||
test('only patches touched base fields and preserves untouched plugin values', () => {
|
||||
const draft = buildPluginConfigDraft(
|
||||
{ enabled: true, configFields: fields },
|
||||
{ priority: 3, mixed: [1, true, { x: 1 }] }
|
||||
);
|
||||
draft.priority = '5';
|
||||
draft.priorityTouched = true;
|
||||
|
||||
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
|
||||
patch: { priority: 5 },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('parses touched array JSON without coercing item types', () => {
|
||||
const draft = buildPluginConfigDraft(
|
||||
{ enabled: true, configFields: fields },
|
||||
{ mixed: ['old'] }
|
||||
);
|
||||
draft.values.mixed = '[1, false, {"next": 2}]';
|
||||
draft.touchedFields.mixed = true;
|
||||
|
||||
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
|
||||
patch: { mixed: [1, false, { next: 2 }] },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('writes a missing boolean only after the user touches it', () => {
|
||||
const draft = buildPluginConfigDraft({ enabled: true, configFields: fields }, {});
|
||||
draft.touchedFields.optional = true;
|
||||
|
||||
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
|
||||
patch: { optional: false },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('deletes a cleared touched field with null', () => {
|
||||
const draft = buildPluginConfigDraft(
|
||||
{ enabled: true, configFields: fields },
|
||||
{ label: 'keep me' }
|
||||
);
|
||||
draft.values.label = ' ';
|
||||
draft.touchedFields.label = true;
|
||||
|
||||
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
|
||||
patch: { label: null },
|
||||
errors: {},
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-array JSON for an array field', () => {
|
||||
const draft = buildPluginConfigDraft(
|
||||
{ enabled: true, configFields: fields },
|
||||
{ mixed: ['old'] }
|
||||
);
|
||||
draft.values.mixed = '{"not":"an array"}';
|
||||
draft.touchedFields.mixed = true;
|
||||
|
||||
expect(buildPluginConfigPatch(draft, fields, t)).toEqual({
|
||||
patch: {},
|
||||
errors: { mixed: 'plugin_management.expected_array' },
|
||||
});
|
||||
});
|
||||
});
|
||||
16
frontend/tests/pluginTrust.test.ts
Normal file
16
frontend/tests/pluginTrust.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { isOfficialPlugin } from '../src/features/plugins/pluginResources';
|
||||
import type { PluginStoreEntry } from '../src/types';
|
||||
|
||||
const pluginEntry = (sourceId: string, repository: string) =>
|
||||
({ sourceId, repository }) as PluginStoreEntry;
|
||||
|
||||
describe('plugin store trust', () => {
|
||||
test('trusts only the official source with an official repository', () => {
|
||||
expect(isOfficialPlugin(pluginEntry('official', 'router-for-me/example-plugin'))).toBe(true);
|
||||
expect(isOfficialPlugin(pluginEntry('third-party', 'router-for-me/example-plugin'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isOfficialPlugin(pluginEntry('official', 'someone-else/example-plugin'))).toBe(false);
|
||||
});
|
||||
});
|
||||
11
frontend/tests/pluginVersionSelection.test.ts
Normal file
11
frontend/tests/pluginVersionSelection.test.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { supportsPluginVersionSelection } from '../src/features/plugins/pluginReleaseVersions';
|
||||
|
||||
describe('plugin version selection', () => {
|
||||
test('allows custom versions only for GitHub release installs', () => {
|
||||
expect(supportsPluginVersionSelection('github-release')).toBe(true);
|
||||
expect(supportsPluginVersionSelection(' GitHub-Release ')).toBe(true);
|
||||
expect(supportsPluginVersionSelection('direct')).toBe(false);
|
||||
expect(supportsPluginVersionSelection('')).toBe(false);
|
||||
});
|
||||
});
|
||||
44
frontend/tests/providerConcurrency.test.ts
Normal file
44
frontend/tests/providerConcurrency.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
appendLatestProviderRecord,
|
||||
replaceLatestProviderRecord,
|
||||
} from '../src/services/api/providers';
|
||||
|
||||
const mergeRecord = (raw: unknown, payload: Record<string, unknown>) => ({
|
||||
...(raw as Record<string, unknown> | undefined),
|
||||
...payload,
|
||||
});
|
||||
|
||||
describe('provider list concurrency', () => {
|
||||
test('preserves concurrent additions while appending a provider', () => {
|
||||
const latest = [
|
||||
{ 'api-key': 'existing', custom: 'keep' },
|
||||
{ 'api-key': 'concurrent', custom: 'also-keep' },
|
||||
];
|
||||
|
||||
expect(appendLatestProviderRecord(latest, { 'api-key': 'created' }, mergeRecord)).toEqual([
|
||||
{ 'api-key': 'existing', custom: 'keep' },
|
||||
{ 'api-key': 'concurrent', custom: 'also-keep' },
|
||||
{ 'api-key': 'created' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('replaces only the selected provider in the latest list', () => {
|
||||
const latest = [
|
||||
{ 'api-key': 'existing', custom: 'keep' },
|
||||
{ 'api-key': 'concurrent', custom: 'also-keep' },
|
||||
];
|
||||
|
||||
expect(
|
||||
replaceLatestProviderRecord(
|
||||
latest,
|
||||
(record) => record['api-key'] === 'existing',
|
||||
{ 'api-key': 'updated' },
|
||||
mergeRecord
|
||||
)
|
||||
).toEqual([
|
||||
{ 'api-key': 'updated', custom: 'keep' },
|
||||
{ 'api-key': 'concurrent', custom: 'also-keep' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
frontend/tests/providerExcludedModelsDisableRule.test.ts
Normal file
40
frontend/tests/providerExcludedModelsDisableRule.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { buildExcludedModels } from '../src/features/providers/useProviderWorkbench';
|
||||
|
||||
/**
|
||||
* `excluded-models: ['*']` 是「该 provider 已停用」的后端编码。
|
||||
* 它的唯一所有者是表单的 `disabled` 开关:载入时被剥离进该 flag,保存时仅凭该 flag 重新追加。
|
||||
*
|
||||
* 这些断言把该不变量钉死,好让排除模型的编辑面(textarea → ExcludedModelsPicker)
|
||||
* 无论怎么重写都不可能污染停用语义。
|
||||
*/
|
||||
describe('buildExcludedModels — the "*" disable-rule invariant', () => {
|
||||
test('appends "*" when disabled', () => {
|
||||
expect(buildExcludedModels('a\nb', true, 'gemini')).toEqual(['a', 'b', '*']);
|
||||
});
|
||||
|
||||
test('omits "*" when not disabled', () => {
|
||||
expect(buildExcludedModels('a\nb', false, 'gemini')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('a hand-typed "*" never duplicates the disable rule', () => {
|
||||
expect(buildExcludedModels('a\n*\nb', true, 'gemini')).toEqual(['a', 'b', '*']);
|
||||
});
|
||||
|
||||
test('a hand-typed "*" never switches the provider to disabled', () => {
|
||||
expect(buildExcludedModels('a\n*\nb', false, 'gemini')).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('disabled with no rules yields exactly the disable rule', () => {
|
||||
expect(buildExcludedModels('', true, 'gemini')).toEqual(['*']);
|
||||
});
|
||||
|
||||
test('no rules and not disabled yields undefined, not an empty array', () => {
|
||||
expect(buildExcludedModels('', false, 'gemini')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('openaiCompatibility never receives the disable rule', () => {
|
||||
expect(buildExcludedModels('a', true, 'openaiCompatibility')).toEqual(['a']);
|
||||
expect(buildExcludedModels('', true, 'openaiCompatibility')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
31
frontend/tests/providerRecentRequestsIsolation.test.ts
Normal file
31
frontend/tests/providerRecentRequestsIsolation.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createProviderRecentRequestsCacheController } from '../src/components/providers/hooks/useProviderRecentRequests';
|
||||
|
||||
describe('provider recent request cache isolation', () => {
|
||||
test('creates a fresh cache when the backend or management key changes', () => {
|
||||
const controller = createProviderRecentRequestsCacheController();
|
||||
const serverA = controller.forScope('https://server-a.example', 'key-a');
|
||||
serverA.cachedUsageByProvider = new Map([['provider-a', new Map()]]);
|
||||
serverA.cachedAt = Date.now();
|
||||
serverA.inFlightRequest = Promise.resolve(serverA.cachedUsageByProvider);
|
||||
|
||||
const serverB = controller.forScope('https://server-b.example', 'key-b');
|
||||
|
||||
expect(serverB).not.toBe(serverA);
|
||||
expect(serverB.cachedUsageByProvider.size).toBe(0);
|
||||
expect(serverB.cachedAt).toBe(0);
|
||||
expect(serverB.inFlightRequest).toBeNull();
|
||||
|
||||
serverA.cachedUsageByProvider = new Map([['late-provider-a', new Map()]]);
|
||||
expect(controller.forScope('https://server-b.example', 'key-b')).toBe(serverB);
|
||||
expect(serverB.cachedUsageByProvider.size).toBe(0);
|
||||
});
|
||||
|
||||
test('reuses the cache only within the same connection scope', () => {
|
||||
const controller = createProviderRecentRequestsCacheController();
|
||||
const first = controller.forScope('https://server.example', 'key-a');
|
||||
|
||||
expect(controller.forScope('https://server.example', 'key-a')).toBe(first);
|
||||
expect(controller.forScope('https://server.example', 'key-b')).not.toBe(first);
|
||||
});
|
||||
});
|
||||
100
frontend/tests/providerThinkingConfig.test.ts
Normal file
100
frontend/tests/providerThinkingConfig.test.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { apiClient } from '../src/services/api/client';
|
||||
import { providersApi } from '../src/services/api/providers';
|
||||
|
||||
const originalGet = apiClient.get;
|
||||
const originalPut = apiClient.put;
|
||||
|
||||
afterEach(() => {
|
||||
apiClient.get = originalGet;
|
||||
apiClient.put = originalPut;
|
||||
});
|
||||
|
||||
describe('provider model thinking config', () => {
|
||||
test('serializes thinking overrides for Vertex models', async () => {
|
||||
let putData: unknown;
|
||||
apiClient.get = (async () => ({ 'vertex-api-key': [] })) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
putData = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.createVertexConfig({
|
||||
apiKey: 'vertex-key',
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3-pro',
|
||||
alias: 'vertex-pro',
|
||||
thinking: {
|
||||
min: 128,
|
||||
max: 32768,
|
||||
zero_allowed: true,
|
||||
dynamic_allowed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(putData).toEqual([
|
||||
{
|
||||
'api-key': 'vertex-key',
|
||||
models: [
|
||||
{
|
||||
name: 'gemini-3-pro',
|
||||
alias: 'vertex-pro',
|
||||
thinking: {
|
||||
min: 128,
|
||||
max: 32768,
|
||||
zero_allowed: true,
|
||||
dynamic_allowed: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('can clear thinking while preserving unknown model fields', async () => {
|
||||
let putData: unknown;
|
||||
apiClient.get = (async () => ({
|
||||
'codex-api-key': [
|
||||
{
|
||||
'api-key': 'codex-key',
|
||||
'base-url': 'https://example.com',
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-5-codex',
|
||||
alias: 'codex-latest',
|
||||
thinking: { levels: ['low', 'high'] },
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
putData = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.updateCodexConfig('codex-key', 'https://example.com', {
|
||||
apiKey: 'codex-key',
|
||||
baseUrl: 'https://example.com',
|
||||
models: [{ name: 'gpt-5-codex', alias: 'codex-latest' }],
|
||||
});
|
||||
|
||||
expect(putData).toEqual([
|
||||
{
|
||||
'api-key': 'codex-key',
|
||||
'base-url': 'https://example.com',
|
||||
models: [
|
||||
{
|
||||
'future-field': 'preserved',
|
||||
name: 'gpt-5-codex',
|
||||
alias: 'codex-latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
106
frontend/tests/providerWeightTransformers.test.ts
Normal file
106
frontend/tests/providerWeightTransformers.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { apiClient } from '../src/services/api/client';
|
||||
import { providersApi } from '../src/services/api/providers';
|
||||
import {
|
||||
normalizeGeminiKeyConfig,
|
||||
normalizeOpenAIProvider,
|
||||
normalizeProviderKeyConfig,
|
||||
} from '../src/services/api/transformers';
|
||||
|
||||
const originalGet = apiClient.get;
|
||||
const originalPut = apiClient.put;
|
||||
|
||||
afterEach(() => {
|
||||
apiClient.get = originalGet;
|
||||
apiClient.put = originalPut;
|
||||
});
|
||||
|
||||
describe('provider credential weight normalization', () => {
|
||||
test('reads weight for direct API key credentials', () => {
|
||||
expect(normalizeGeminiKeyConfig({ 'api-key': 'gemini-key', weight: 5 })?.weight).toBe(5);
|
||||
expect(normalizeProviderKeyConfig({ 'api-key': 'provider-key', weight: 0 })?.weight).toBe(0);
|
||||
});
|
||||
|
||||
test('reads per-key weight for OpenAI-compatible providers', () => {
|
||||
const provider = normalizeOpenAIProvider({
|
||||
name: 'example',
|
||||
'base-url': 'https://example.com/v1',
|
||||
'api-key-entries': [{ 'api-key': 'key-a', weight: 3 }, { 'api-key': 'key-b' }],
|
||||
});
|
||||
|
||||
expect(provider?.apiKeyEntries[0]?.weight).toBe(3);
|
||||
expect(provider?.apiKeyEntries[1]?.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
test('removes a cleared Vertex weight while preserving unknown fields', async () => {
|
||||
let written: unknown;
|
||||
apiClient.get = (async () => ({
|
||||
'vertex-api-key': [
|
||||
{
|
||||
'api-key': 'vertex-key',
|
||||
'base-url': 'https://vertex.example',
|
||||
weight: 9,
|
||||
'future-field': 'keep',
|
||||
},
|
||||
],
|
||||
})) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
written = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.updateVertexConfig('vertex-key', 'https://vertex.example', {
|
||||
apiKey: 'vertex-key',
|
||||
baseUrl: 'https://vertex.example',
|
||||
weight: undefined,
|
||||
});
|
||||
|
||||
expect(written).toEqual([
|
||||
{
|
||||
'api-key': 'vertex-key',
|
||||
'base-url': 'https://vertex.example',
|
||||
'future-field': 'keep',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('writes and clears nested OpenAI-compatible key weights', async () => {
|
||||
let written: unknown;
|
||||
apiClient.get = (async () => ({
|
||||
'openai-compatibility': [
|
||||
{
|
||||
name: 'example',
|
||||
'base-url': 'https://example.com/v1',
|
||||
'api-key-entries': [
|
||||
{ 'api-key': 'key-a', weight: 8, custom: 'keep-a' },
|
||||
{ 'api-key': 'key-b', custom: 'keep-b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})) as typeof apiClient.get;
|
||||
apiClient.put = (async (_url: string, data?: unknown) => {
|
||||
written = data;
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
|
||||
await providersApi.updateOpenAIProvider('example', 0, {
|
||||
name: 'example',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
apiKeyEntries: [
|
||||
{ apiKey: 'key-a', weight: undefined },
|
||||
{ apiKey: 'key-b', weight: 4 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(written).toEqual([
|
||||
{
|
||||
name: 'example',
|
||||
'base-url': 'https://example.com/v1',
|
||||
'api-key-entries': [
|
||||
{ 'api-key': 'key-a', custom: 'keep-a' },
|
||||
{ 'api-key': 'key-b', custom: 'keep-b', weight: 4 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
199
frontend/tests/quotaBodyRendering.test.ts
Normal file
199
frontend/tests/quotaBodyRendering.test.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Provider bodies rendered end-to-end.
|
||||
*
|
||||
* Bodies receive their class map as a prop and import no stylesheet, so unlike
|
||||
* QuotaCard they can be rendered directly here — which is the only place the
|
||||
* "absolute plus countdown" pairing is checked as actual markup rather than as
|
||||
* a formatter's return value.
|
||||
*/
|
||||
|
||||
import { beforeAll, describe, expect, test } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import i18n from '@/i18n';
|
||||
import { CodexQuotaBody } from '@/features/quota/providers/codex/CodexQuotaBody';
|
||||
import { ClaudeQuotaBody } from '@/features/quota/providers/claude/ClaudeQuotaBody';
|
||||
import { KimiQuotaBody } from '@/features/quota/providers/kimi/KimiQuotaBody';
|
||||
import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types';
|
||||
import { formatInstantShort } from '@/utils/quota';
|
||||
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
||||
import type { ClaudeQuotaState, CodexQuotaState, KimiQuotaState } from '@/types';
|
||||
|
||||
const classes = bindQuotaClasses(
|
||||
Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, key])),
|
||||
'test-host'
|
||||
);
|
||||
|
||||
/**
|
||||
* useNow() freezes to module-load time under renderToStaticMarkup (it reads
|
||||
* getServerSnapshot), so instants are placed relative to the real clock.
|
||||
*/
|
||||
const now = Date.now();
|
||||
|
||||
// The i18n fallback is zh-CN; pin English so the countdown assertions read.
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
describe('CodexQuotaBody', () => {
|
||||
const quota: CodexQuotaState = {
|
||||
status: 'success',
|
||||
planType: 'pro',
|
||||
windows: [
|
||||
{
|
||||
id: 'primary',
|
||||
label: '5-hour limit',
|
||||
usedPercent: 38,
|
||||
resetLabel: '08-02 18:00',
|
||||
resetAtMs: now + 3 * HOUR_MS,
|
||||
periodHours: 5,
|
||||
},
|
||||
],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: new Date(now - DAY_MS).toISOString(),
|
||||
expiresAt: new Date(now + 11 * DAY_MS).toISOString(),
|
||||
},
|
||||
],
|
||||
rateLimitResetCreditsAvailableCount: 1,
|
||||
};
|
||||
|
||||
test('renders a window reset as absolute plus countdown', () => {
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain('08-02 18:00');
|
||||
expect(markup).toContain('quotaResetRelative');
|
||||
expect(markup).toMatch(/3 hours/);
|
||||
});
|
||||
|
||||
test('renders reset-credit expiry in local time with a countdown', () => {
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain(formatInstantShort(now + 11 * DAY_MS));
|
||||
expect(markup).toMatch(/11 days/);
|
||||
});
|
||||
|
||||
test('highlights a credit expiring within the final hour', () => {
|
||||
const creditFirst: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now + 5 * DAY_MS }],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: new Date(now - DAY_MS).toISOString(),
|
||||
expiresAt: new Date(now + 30 * 60_000).toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(CodexQuotaBody, { quota: creditFirst, classes })
|
||||
);
|
||||
|
||||
expect(markup).toContain('codexResetCreditRowSoon');
|
||||
expect(markup).not.toContain('quotaRowSoon');
|
||||
});
|
||||
|
||||
test('does not emphasize a reset countdown more than one hour away', () => {
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).not.toContain('quotaRowSoon');
|
||||
expect(markup).not.toContain('quotaResetRelativeSoon');
|
||||
expect(markup).not.toContain('codexResetCreditRowSoon');
|
||||
});
|
||||
|
||||
test('emphasizes a reset countdown within the final hour', () => {
|
||||
const urgent: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now + 30 * 60_000 }],
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: urgent, classes }));
|
||||
|
||||
expect(markup).toContain('quotaResetRelativeSoon');
|
||||
});
|
||||
|
||||
test('highlights nothing once every instant is in the past', () => {
|
||||
const stale: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: now - HOUR_MS }],
|
||||
rateLimitResetCredits: [],
|
||||
rateLimitResetCreditsAvailableCount: null,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: stale, classes }));
|
||||
|
||||
expect(markup).not.toContain('Soon');
|
||||
});
|
||||
|
||||
test('keeps the baked label alone when the store entry predates resetAtMs', () => {
|
||||
const stale: CodexQuotaState = {
|
||||
...quota,
|
||||
windows: [{ ...quota.windows[0], resetAtMs: undefined, periodHours: undefined }],
|
||||
rateLimitResetCredits: [],
|
||||
rateLimitResetCreditsAvailableCount: null,
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(CodexQuotaBody, { quota: stale, classes }));
|
||||
|
||||
expect(markup).toContain('08-02 18:00');
|
||||
expect(markup).not.toContain('quotaResetRelative');
|
||||
});
|
||||
});
|
||||
|
||||
describe('KimiQuotaBody', () => {
|
||||
test('renders the concrete reset time alongside its countdown', () => {
|
||||
const resetAtMs = now + 3 * HOUR_MS;
|
||||
const quota: KimiQuotaState = {
|
||||
status: 'success',
|
||||
rows: [
|
||||
{
|
||||
id: 'summary',
|
||||
label: 'Weekly limit',
|
||||
used: 34,
|
||||
limit: 100,
|
||||
resetHint: '3h',
|
||||
resetAtMs,
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(KimiQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain(formatInstantShort(resetAtMs));
|
||||
expect(markup).toContain('quotaResetRelative');
|
||||
expect(markup).toMatch(/3 hours/);
|
||||
expect(markup).not.toContain('resets in 3h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClaudeQuotaBody', () => {
|
||||
test('pairs each window reset with a countdown', () => {
|
||||
const quota: ClaudeQuotaState = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{
|
||||
id: 'five_hour',
|
||||
label: '5-hour',
|
||||
usedPercent: 12,
|
||||
resetLabel: '08-02 17:00',
|
||||
resetAtMs: now + 2 * HOUR_MS,
|
||||
periodHours: 5,
|
||||
},
|
||||
{
|
||||
id: 'seven_day',
|
||||
label: '7-day',
|
||||
usedPercent: 60,
|
||||
resetLabel: '08-06 04:00',
|
||||
resetAtMs: now + 4 * DAY_MS,
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
};
|
||||
const markup = renderToStaticMarkup(createElement(ClaudeQuotaBody, { quota, classes }));
|
||||
|
||||
expect(markup).toContain('08-02 17:00');
|
||||
expect(markup).toContain('08-06 04:00');
|
||||
expect(markup).toMatch(/2 hours/);
|
||||
expect(markup).toMatch(/4 days/);
|
||||
});
|
||||
});
|
||||
49
frontend/tests/quotaClassContract.test.ts
Normal file
49
frontend/tests/quotaClassContract.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Guard for the QuotaClassMap ↔ SCSS contract.
|
||||
*
|
||||
* `bindQuotaClasses` throws at *module initialization*, and both hosts bind at
|
||||
* import time — so a contract key missing from either stylesheet does not
|
||||
* degrade one row, it white-screens the whole application. This converts that
|
||||
* into a failing test.
|
||||
*
|
||||
* The check is textual rather than a module import so it validates the source
|
||||
* selectors instead of Vite's generated class-name object.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { QUOTA_CLASS_KEYS, bindQuotaClasses } from '@/features/quota/types';
|
||||
|
||||
const HOSTS = {
|
||||
'QuotaBody.module.scss': 'src/features/quota/components/QuotaBody.module.scss',
|
||||
'AuthFileQuota.module.scss': 'src/features/authFiles/components/AuthFileQuota.module.scss',
|
||||
} as const;
|
||||
|
||||
const readHost = (path: string) => readFile(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
|
||||
describe('quota class contract', () => {
|
||||
for (const [host, path] of Object.entries(HOSTS)) {
|
||||
test(`${host} defines every QuotaClassMap key`, async () => {
|
||||
const css = await readHost(path);
|
||||
const missing = QUOTA_CLASS_KEYS.filter(
|
||||
(key) => !new RegExp(`^\\s*\\.${key}\\b`, 'm').test(css)
|
||||
);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('bindQuotaClasses reports every missing key rather than the first', () => {
|
||||
const partial = Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, `_${key}`]));
|
||||
delete partial.quotaReset;
|
||||
delete partial.quotaResetRelative;
|
||||
|
||||
expect(() => bindQuotaClasses(partial, 'test-host')).toThrow(/quotaReset.*quotaResetRelative/);
|
||||
});
|
||||
|
||||
test('bindQuotaClasses returns exactly the contract keys', () => {
|
||||
const full = Object.fromEntries(QUOTA_CLASS_KEYS.map((key) => [key, `_${key}`]));
|
||||
const bound = bindQuotaClasses({ ...full, strayKey: '_stray' }, 'test-host');
|
||||
|
||||
expect(Object.keys(bound).sort()).toEqual([...QUOTA_CLASS_KEYS].sort());
|
||||
});
|
||||
});
|
||||
187
frontend/tests/quotaPageLogic.test.ts
Normal file
187
frontend/tests/quotaPageLogic.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { QUOTA_PAGE_SIZE } from '@/features/quota/constants';
|
||||
import {
|
||||
buildTabCounts,
|
||||
classifyQuotaFiles,
|
||||
filterEntriesByTab,
|
||||
isQuotaRefreshDisabled,
|
||||
paginate,
|
||||
resolveQuotaProviderType,
|
||||
sortQuotaEntries,
|
||||
type QuotaFileEntry,
|
||||
} from '@/features/quota/logic';
|
||||
import type { AuthFileItem } from '@/types';
|
||||
|
||||
const file = (name: string, provider: string, extra: Partial<AuthFileItem> = {}): AuthFileItem =>
|
||||
({ name, provider, ...extra }) as AuthFileItem;
|
||||
|
||||
const FILES: AuthFileItem[] = [
|
||||
file('codex-a.json', 'codex'),
|
||||
file('claude-a.json', 'claude'),
|
||||
file('kimi-a.json', 'kimi'),
|
||||
file('codex-b.json', 'codex'),
|
||||
file('grok-a.json', 'grok'), // 别名归一到 xai
|
||||
file('gemini-a.json', 'gemini'), // 不支持额度
|
||||
file('claude-off.json', 'claude', { disabled: true }), // 停用
|
||||
];
|
||||
|
||||
describe('resolveQuotaProviderType', () => {
|
||||
test('maps provider aliases and rejects unsupported or disabled files', () => {
|
||||
expect(resolveQuotaProviderType(file('a', 'grok'))).toBe('xai');
|
||||
expect(resolveQuotaProviderType(file('a', 'antigravity'))).toBe('antigravity');
|
||||
expect(resolveQuotaProviderType(file('a', 'gemini'))).toBeNull();
|
||||
expect(resolveQuotaProviderType(file('a', 'claude', { disabled: true }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyQuotaFiles', () => {
|
||||
test('drops unsupported and disabled files', () => {
|
||||
const entries = classifyQuotaFiles(FILES);
|
||||
expect(entries.map((entry) => entry.file.name)).not.toContain('gemini-a.json');
|
||||
expect(entries.map((entry) => entry.file.name)).not.toContain('claude-off.json');
|
||||
expect(entries).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('orders entries by provider tab order', () => {
|
||||
const entries = classifyQuotaFiles(FILES);
|
||||
expect(entries.map((entry) => entry.type)).toEqual(['claude', 'codex', 'codex', 'xai', 'kimi']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTabCounts', () => {
|
||||
test('counts per provider plus an all total, zero-filling empty tabs', () => {
|
||||
expect(buildTabCounts(classifyQuotaFiles(FILES))).toEqual({
|
||||
all: 5,
|
||||
claude: 1,
|
||||
antigravity: 0,
|
||||
codex: 2,
|
||||
xai: 1,
|
||||
kimi: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEntriesByTab', () => {
|
||||
const entries = classifyQuotaFiles(FILES);
|
||||
|
||||
test("passes everything through on the 'all' tab", () => {
|
||||
expect(filterEntriesByTab(entries, 'all')).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('filters to a single provider', () => {
|
||||
expect(filterEntriesByTab(entries, 'codex').map((entry) => entry.file.name)).toEqual([
|
||||
'codex-a.json',
|
||||
'codex-b.json',
|
||||
]);
|
||||
expect(filterEntriesByTab(entries, 'antigravity')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuotaRefreshDisabled', () => {
|
||||
test('blocks a single-card refresh while the same quota is resetting', () => {
|
||||
expect(isQuotaRefreshDisabled(true, false, true)).toBe(true);
|
||||
expect(isQuotaRefreshDisabled(true, false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paginate', () => {
|
||||
const items = Array.from({ length: 45 }, (_, index) => index);
|
||||
|
||||
test('uses the configured 20-item page size', () => {
|
||||
expect(QUOTA_PAGE_SIZE).toBe(20);
|
||||
expect(paginate(items, 2, QUOTA_PAGE_SIZE)).toEqual({
|
||||
pageItems: items.slice(20, 40),
|
||||
currentPage: 2,
|
||||
totalPages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps an out-of-range page instead of returning an empty slice', () => {
|
||||
expect(paginate(items, 9, QUOTA_PAGE_SIZE).currentPage).toBe(3);
|
||||
expect(paginate(items, 9, QUOTA_PAGE_SIZE).pageItems).toEqual(items.slice(40));
|
||||
expect(paginate(items, 0, QUOTA_PAGE_SIZE).currentPage).toBe(1);
|
||||
});
|
||||
|
||||
test('keeps at least one page when the list is empty', () => {
|
||||
expect(paginate([], 1, QUOTA_PAGE_SIZE)).toEqual({
|
||||
pageItems: [],
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortQuotaEntries', () => {
|
||||
const entries = classifyQuotaFiles(FILES);
|
||||
const byName = (list: QuotaFileEntry[]) => list.map((entry) => entry.file.name);
|
||||
|
||||
/** Recovery instants keyed by file name; anything absent resolves to null. */
|
||||
const resolver = (instants: Record<string, number>) => (entry: QuotaFileEntry) =>
|
||||
instants[entry.file.name] ?? null;
|
||||
|
||||
test('default mode preserves order but returns a new array', () => {
|
||||
const sorted = sortQuotaEntries(entries, 'default', () => 1);
|
||||
expect(byName(sorted)).toEqual(byName(entries));
|
||||
expect(sorted).not.toBe(entries);
|
||||
});
|
||||
|
||||
test('orders loaded credentials by how soon they recover, across providers', () => {
|
||||
const sorted = sortQuotaEntries(
|
||||
entries,
|
||||
'soonest',
|
||||
resolver({
|
||||
'codex-a.json': 300,
|
||||
'claude-a.json': 100,
|
||||
'kimi-a.json': 200,
|
||||
'codex-b.json': 400,
|
||||
'grok-a.json': 50,
|
||||
})
|
||||
);
|
||||
expect(byName(sorted)).toEqual([
|
||||
'grok-a.json',
|
||||
'claude-a.json',
|
||||
'kimi-a.json',
|
||||
'codex-a.json',
|
||||
'codex-b.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sinks credentials with no instant, keeping their provider-grouped order', () => {
|
||||
// Loading is click-to-fetch, so an unloaded tail is the normal case.
|
||||
const sorted = sortQuotaEntries(
|
||||
entries,
|
||||
'soonest',
|
||||
resolver({ 'codex-b.json': 200, 'kimi-a.json': 100 })
|
||||
);
|
||||
expect(byName(sorted)).toEqual([
|
||||
'kimi-a.json',
|
||||
'codex-b.json',
|
||||
// unresolved tail, in the order classifyQuotaFiles produced
|
||||
'claude-a.json',
|
||||
'codex-a.json',
|
||||
'grok-a.json',
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves the order untouched when nothing has loaded', () => {
|
||||
expect(byName(sortQuotaEntries(entries, 'soonest', () => null))).toEqual(byName(entries));
|
||||
});
|
||||
|
||||
test('breaks ties on the original position, so equal instants stay stable', () => {
|
||||
const sorted = sortQuotaEntries(entries, 'soonest', () => 500);
|
||||
expect(byName(sorted)).toEqual(byName(entries));
|
||||
});
|
||||
|
||||
test('does not mutate the input', () => {
|
||||
const input = [...entries];
|
||||
sortQuotaEntries(input, 'soonest', resolver({ 'codex-b.json': 1 }));
|
||||
expect(input).toEqual(entries);
|
||||
});
|
||||
|
||||
test('sorts before paginating, so the globally soonest lands on page one', () => {
|
||||
// Last in the default order, first to recover.
|
||||
const last = entries[entries.length - 1].file.name;
|
||||
const sorted = sortQuotaEntries(entries, 'soonest', resolver({ [last]: 1 }));
|
||||
expect(paginate(sorted, 1, 2).pageItems[0].file.name).toBe(last);
|
||||
});
|
||||
});
|
||||
41
frontend/tests/quotaPlanTier.test.ts
Normal file
41
frontend/tests/quotaPlanTier.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
ELITE_CODEX_PLAN_TYPE,
|
||||
PREMIUM_CODEX_PLAN_TYPES,
|
||||
resolvePlanTier,
|
||||
} from '@/utils/quota';
|
||||
|
||||
describe('resolvePlanTier', () => {
|
||||
test("elite wins for 'pro' even though it is also in the premium set (order contract)", () => {
|
||||
// 顺序契约回归:'pro' 同时命中 PREMIUM_CODEX_PLAN_TYPES,
|
||||
// 一旦 premium 判断先行,Pro 20x 会静默退回金卡。
|
||||
expect(PREMIUM_CODEX_PLAN_TYPES.has(ELITE_CODEX_PLAN_TYPE)).toBe(true);
|
||||
expect(resolvePlanTier('pro')).toBe('elite');
|
||||
});
|
||||
|
||||
test('normalizes case and whitespace before matching', () => {
|
||||
expect(resolvePlanTier('PRO')).toBe('elite');
|
||||
expect(resolvePlanTier(' Pro ')).toBe('elite');
|
||||
expect(resolvePlanTier('Pro-Lite')).toBe('premium');
|
||||
});
|
||||
|
||||
test('maps every pro-lite spelling to premium', () => {
|
||||
expect(resolvePlanTier('prolite')).toBe('premium');
|
||||
expect(resolvePlanTier('pro-lite')).toBe('premium');
|
||||
expect(resolvePlanTier('pro_lite')).toBe('premium');
|
||||
});
|
||||
|
||||
test('maps ordinary and unknown plans to plain', () => {
|
||||
expect(resolvePlanTier('plus')).toBe('plain');
|
||||
expect(resolvePlanTier('team')).toBe('plain');
|
||||
expect(resolvePlanTier('free')).toBe('plain');
|
||||
expect(resolvePlanTier('enterprise')).toBe('plain');
|
||||
});
|
||||
|
||||
test('maps missing values to plain', () => {
|
||||
expect(resolvePlanTier(null)).toBe('plain');
|
||||
expect(resolvePlanTier(undefined)).toBe('plain');
|
||||
expect(resolvePlanTier('')).toBe('plain');
|
||||
expect(resolvePlanTier(' ')).toBe('plain');
|
||||
});
|
||||
});
|
||||
127
frontend/tests/quotaRelativeTime.test.ts
Normal file
127
frontend/tests/quotaRelativeTime.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* Relative-time formatting for quota cards.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildResetDisplay,
|
||||
formatInstantShort,
|
||||
formatQuotaResetTime,
|
||||
formatRelativeInstant,
|
||||
relativeTimeParts,
|
||||
} from '@/utils/quota';
|
||||
import { DAY_MS, HOUR_MS, MINUTE_MS } from '@/utils/time/durations';
|
||||
|
||||
const NOW = new Date(2026, 7, 2, 12, 0, 0).getTime();
|
||||
|
||||
describe('relativeTimeParts', () => {
|
||||
test('picks the coarsest unit that still describes the gap', () => {
|
||||
expect(relativeTimeParts(NOW + DAY_MS, NOW)).toEqual({ value: 1, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW + HOUR_MS, NOW)).toEqual({ value: 1, unit: 'hour' });
|
||||
expect(relativeTimeParts(NOW + 30_000, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
});
|
||||
|
||||
test('never renders a magnitude that should have been the next unit up', () => {
|
||||
// Rounding up here would produce "24 hours" and "60 minutes".
|
||||
expect(relativeTimeParts(NOW + DAY_MS - 1, NOW)).toEqual({ value: 23, unit: 'hour' });
|
||||
expect(relativeTimeParts(NOW + HOUR_MS - 1, NOW)).toEqual({ value: 59, unit: 'minute' });
|
||||
});
|
||||
|
||||
test('truncates, so a deadline never appears further off than it is', () => {
|
||||
expect(relativeTimeParts(NOW + 11 * DAY_MS + 1, NOW)).toEqual({ value: 11, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW + 11 * DAY_MS + 23 * HOUR_MS, NOW)).toEqual({
|
||||
value: 11,
|
||||
unit: 'day',
|
||||
});
|
||||
expect(relativeTimeParts(NOW + 90 * MINUTE_MS, NOW)).toEqual({ value: 1, unit: 'hour' });
|
||||
});
|
||||
|
||||
test('past instants are negative rather than clamped to zero', () => {
|
||||
// The timeline-local predecessor clamped with Math.max(0, …), so an expired
|
||||
// credit read "in 1 minute".
|
||||
expect(relativeTimeParts(NOW - 3 * DAY_MS, NOW)).toEqual({ value: -3, unit: 'day' });
|
||||
expect(relativeTimeParts(NOW - 2 * HOUR_MS, NOW)).toEqual({ value: -2, unit: 'hour' });
|
||||
});
|
||||
|
||||
test('sub-minute magnitudes floor to 1 in both directions', () => {
|
||||
expect(relativeTimeParts(NOW + 1, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
expect(relativeTimeParts(NOW, NOW)).toEqual({ value: 1, unit: 'minute' });
|
||||
expect(relativeTimeParts(NOW - 1, NOW)).toEqual({ value: -1, unit: 'minute' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeInstant', () => {
|
||||
test('renders in the requested locale', () => {
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'en')).toContain('days');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-CN')).toContain('天');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'zh-TW')).toContain('天');
|
||||
expect(formatRelativeInstant(NOW + 11 * DAY_MS, NOW, 'ru')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('distinguishes past from future', () => {
|
||||
const future = formatRelativeInstant(NOW + 3 * DAY_MS, NOW, 'en');
|
||||
const past = formatRelativeInstant(NOW - 3 * DAY_MS, NOW, 'en');
|
||||
expect(future).not.toBe(past);
|
||||
expect(past).toContain('ago');
|
||||
});
|
||||
|
||||
test('falls back instead of throwing on an unusable locale tag', () => {
|
||||
expect(() => formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).not.toThrow();
|
||||
expect(formatRelativeInstant(NOW + DAY_MS, NOW, 'not-a-locale!!')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatInstantShort', () => {
|
||||
test('matches the shape the baked reset labels already use', () => {
|
||||
const iso = new Date(2026, 7, 13, 14, 30).toISOString();
|
||||
expect(formatInstantShort(new Date(iso).getTime())).toBe(formatQuotaResetTime(iso));
|
||||
});
|
||||
|
||||
test('degrades to a dash rather than "Invalid Date"', () => {
|
||||
expect(formatInstantShort(Number.NaN)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildResetDisplay', () => {
|
||||
test('pairs a baked absolute label with a computed relative one', () => {
|
||||
const display = buildResetDisplay('08-13 14:30', NOW + 11 * DAY_MS, NOW, 'en');
|
||||
expect(display).not.toBeNull();
|
||||
expect(display?.absolute).toBe('08-13 14:30');
|
||||
expect(display?.relative).toContain('11 days');
|
||||
});
|
||||
|
||||
test('keeps the baked label alone when the instant is missing', () => {
|
||||
// A store entry cached by an older build carries resetLabel but no resetAtMs.
|
||||
expect(buildResetDisplay('08-13 14:30', null, NOW, 'en')).toEqual({
|
||||
absolute: '08-13 14:30',
|
||||
relative: null,
|
||||
});
|
||||
expect(buildResetDisplay('08-13 14:30', undefined, NOW, 'en')?.relative).toBeNull();
|
||||
});
|
||||
|
||||
test('derives the absolute half from the instant when no label was baked', () => {
|
||||
const at = NOW + 2 * HOUR_MS;
|
||||
const display = buildResetDisplay(undefined, at, NOW, 'en');
|
||||
expect(display?.absolute).toBe(formatInstantShort(at));
|
||||
expect(display?.relative).toContain('2 hours');
|
||||
});
|
||||
|
||||
test('returns null when there is nothing to render', () => {
|
||||
expect(buildResetDisplay(undefined, null, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay('', null, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay(' ', null, NOW, 'en')).toBeNull();
|
||||
// '-' is the providers' own placeholder for "no reset known".
|
||||
expect(buildResetDisplay('-', null, NOW, 'en')).toBeNull();
|
||||
});
|
||||
|
||||
test('treats a placeholder label with a real instant as renderable', () => {
|
||||
const display = buildResetDisplay('-', NOW + DAY_MS, NOW, 'en');
|
||||
expect(display?.absolute).toBe(formatInstantShort(NOW + DAY_MS));
|
||||
expect(display?.relative).toContain('1 day');
|
||||
});
|
||||
|
||||
test('rejects a non-finite instant', () => {
|
||||
expect(buildResetDisplay(undefined, Number.NaN, NOW, 'en')).toBeNull();
|
||||
expect(buildResetDisplay('08-13 14:30', Number.POSITIVE_INFINITY, NOW, 'en')?.relative).toBeNull();
|
||||
});
|
||||
});
|
||||
101
frontend/tests/quotaResetInstants.test.ts
Normal file
101
frontend/tests/quotaResetInstants.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
claudePeriodHours,
|
||||
parseIsoToMs,
|
||||
parseOffsetSecondsToMs,
|
||||
parseUnixToMs,
|
||||
periodHoursFromSeconds,
|
||||
resolveResetMs,
|
||||
} from '../src/utils/quota/resetInstants';
|
||||
|
||||
describe('parseIsoToMs', () => {
|
||||
test('parses a plain ISO timestamp', () => {
|
||||
expect(parseIsoToMs('2026-07-29T14:59:00Z')).toBe(Date.UTC(2026, 6, 29, 14, 59));
|
||||
});
|
||||
|
||||
test('tolerates over-precise fractional seconds', () => {
|
||||
// Some providers emit nanoseconds, which Date rejects on some engines.
|
||||
expect(parseIsoToMs('2026-07-29T14:59:00.123456789Z')).toBe(
|
||||
Date.UTC(2026, 6, 29, 14, 59, 0, 123)
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-strings, blanks and unparseable text', () => {
|
||||
expect(parseIsoToMs(undefined)).toBeNull();
|
||||
expect(parseIsoToMs(' ')).toBeNull();
|
||||
expect(parseIsoToMs('not a date')).toBeNull();
|
||||
expect(parseIsoToMs(1754000000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseUnixToMs', () => {
|
||||
test('treats small magnitudes as seconds and large as milliseconds', () => {
|
||||
expect(parseUnixToMs(1_754_000_000)).toBe(1_754_000_000_000);
|
||||
expect(parseUnixToMs(1_754_000_000_000)).toBe(1_754_000_000_000);
|
||||
});
|
||||
|
||||
test('accepts numeric strings', () => {
|
||||
expect(parseUnixToMs('1754000000')).toBe(1_754_000_000_000);
|
||||
});
|
||||
|
||||
test('rejects zero, negatives and junk', () => {
|
||||
expect(parseUnixToMs(0)).toBeNull();
|
||||
expect(parseUnixToMs(-5)).toBeNull();
|
||||
expect(parseUnixToMs('soon')).toBeNull();
|
||||
expect(parseUnixToMs(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOffsetSecondsToMs', () => {
|
||||
test('projects a countdown forward from now', () => {
|
||||
const now = 1_754_000_000_000;
|
||||
expect(parseOffsetSecondsToMs(3600, now)).toBe(now + 3_600_000);
|
||||
});
|
||||
|
||||
test('rejects a non-positive or unparseable offset', () => {
|
||||
expect(parseOffsetSecondsToMs(0, 1000)).toBeNull();
|
||||
expect(parseOffsetSecondsToMs(-60, 1000)).toBeNull();
|
||||
expect(parseOffsetSecondsToMs('nope', 1000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveResetMs', () => {
|
||||
test('takes the first parseable candidate, ISO or Unix', () => {
|
||||
expect(resolveResetMs([undefined, null, '2026-07-29T14:59:00Z'])).toBe(
|
||||
Date.UTC(2026, 6, 29, 14, 59)
|
||||
);
|
||||
expect(resolveResetMs([undefined, 1_754_000_000])).toBe(1_754_000_000_000);
|
||||
});
|
||||
|
||||
test('prefers an earlier candidate over a later one in the list', () => {
|
||||
// Order is priority, not chronology — callers list their preferred key first.
|
||||
expect(resolveResetMs(['2026-01-01T00:00:00Z', 1_754_000_000])).toBe(
|
||||
Date.UTC(2026, 0, 1)
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when nothing parses', () => {
|
||||
expect(resolveResetMs([])).toBeNull();
|
||||
expect(resolveResetMs([undefined, null, '', 'later'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('period derivation', () => {
|
||||
test('converts a window length in seconds to hours', () => {
|
||||
expect(periodHoursFromSeconds(18_000)).toBe(5);
|
||||
expect(periodHoursFromSeconds(604_800)).toBe(168);
|
||||
expect(periodHoursFromSeconds('18000')).toBe(5);
|
||||
});
|
||||
|
||||
test('rejects an absent or non-positive window length', () => {
|
||||
expect(periodHoursFromSeconds(0)).toBeNull();
|
||||
expect(periodHoursFromSeconds(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test('derives the Claude period from its window key', () => {
|
||||
// Claude states the period nowhere in the payload — only the key implies it.
|
||||
expect(claudePeriodHours('five_hour')).toBe(5);
|
||||
expect(claudePeriodHours('seven_day')).toBe(168);
|
||||
expect(claudePeriodHours('seven_day_opus')).toBe(168);
|
||||
});
|
||||
});
|
||||
239
frontend/tests/quotaResetSchedule.test.ts
Normal file
239
frontend/tests/quotaResetSchedule.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Which row on a quota card recovers first, per provider.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
XAI_WEEKLY_ROW_ID,
|
||||
collectQuotaRowInstants,
|
||||
nextRecoveryMs,
|
||||
pickSoonestRowId,
|
||||
pickUrgentRowId,
|
||||
resetCreditRowId,
|
||||
} from '@/features/quota/resetSchedule';
|
||||
import { DAY_MS, HOUR_MS } from '@/utils/time/durations';
|
||||
|
||||
const NOW = new Date(2026, 7, 2, 12).getTime();
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
const claudeQuota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'five_hour', resetAtMs: NOW + 3 * HOUR_MS },
|
||||
{ id: 'seven_day', resetAtMs: NOW + 4 * DAY_MS },
|
||||
],
|
||||
};
|
||||
|
||||
const codexQuota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'primary', resetAtMs: NOW + 3 * HOUR_MS },
|
||||
{ id: 'secondary', resetAtMs: NOW + 6 * DAY_MS },
|
||||
],
|
||||
rateLimitResetCredits: [
|
||||
{ id: 'credit-a', status: 'available', expiresAt: iso(NOW + 11 * DAY_MS) },
|
||||
{ id: 'credit-b', status: 'available', expiresAt: iso(NOW + 2 * DAY_MS) },
|
||||
],
|
||||
};
|
||||
|
||||
describe('collectQuotaRowInstants', () => {
|
||||
test('collects every Claude window', () => {
|
||||
expect(collectQuotaRowInstants('claude', claudeQuota)).toEqual([
|
||||
{ rowId: 'five_hour', atMs: NOW + 3 * HOUR_MS, kind: 'window' },
|
||||
{ rowId: 'seven_day', atMs: NOW + 4 * DAY_MS, kind: 'window' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('collects Codex windows and available reset credits together', () => {
|
||||
const instants = collectQuotaRowInstants('codex', codexQuota);
|
||||
expect(instants).toHaveLength(4);
|
||||
expect(instants.filter((i) => i.kind === 'credit').map((i) => i.rowId)).toEqual([
|
||||
'credit-a',
|
||||
'credit-b',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores reset credits that are not available', () => {
|
||||
const consumed = {
|
||||
...codexQuota,
|
||||
rateLimitResetCredits: [
|
||||
{ id: 'used', status: 'consumed', expiresAt: iso(NOW + HOUR_MS) },
|
||||
{ id: 'live', status: 'available', expiresAt: iso(NOW + 2 * HOUR_MS) },
|
||||
],
|
||||
};
|
||||
expect(
|
||||
collectQuotaRowInstants('codex', consumed)
|
||||
.filter((i) => i.kind === 'credit')
|
||||
.map((i) => i.rowId)
|
||||
).toEqual(['live']);
|
||||
});
|
||||
|
||||
test('collects the xAI weekly window', () => {
|
||||
const quota = { status: 'success', billing: { periodType: 'weekly', resetAtMs: NOW + DAY_MS } };
|
||||
expect(collectQuotaRowInstants('xai', quota)).toEqual([
|
||||
{ rowId: XAI_WEEKLY_ROW_ID, atMs: NOW + DAY_MS, kind: 'window' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores an xAI monthly summary — a billing cycle is not capacity returning', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
billing: { periodType: 'monthly', resetAtMs: NOW + DAY_MS },
|
||||
};
|
||||
expect(collectQuotaRowInstants('xai', quota)).toEqual([]);
|
||||
});
|
||||
|
||||
test('flattens Antigravity buckets across groups', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
groups: [
|
||||
{ id: 'g1', buckets: [{ id: 'b1', resetAtMs: NOW + HOUR_MS }] },
|
||||
{ id: 'g2', buckets: [{ id: 'b2', resetAtMs: NOW + 2 * HOUR_MS }] },
|
||||
],
|
||||
};
|
||||
expect(collectQuotaRowInstants('antigravity', quota).map((i) => i.rowId)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
test('collects Kimi rows', () => {
|
||||
const quota = { status: 'success', rows: [{ id: 'r1', resetAtMs: NOW + HOUR_MS }] };
|
||||
expect(collectQuotaRowInstants('kimi', quota).map((i) => i.rowId)).toEqual(['r1']);
|
||||
});
|
||||
|
||||
test('returns nothing unless the credential loaded successfully', () => {
|
||||
for (const status of ['idle', 'loading', 'error']) {
|
||||
expect(collectQuotaRowInstants('claude', { ...claudeQuota, status })).toEqual([]);
|
||||
}
|
||||
expect(collectQuotaRowInstants('claude', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test('drops rows with no usable instant rather than emitting NaN', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'ok', resetAtMs: NOW + HOUR_MS },
|
||||
{ id: 'missing' },
|
||||
{ id: 'null', resetAtMs: null },
|
||||
{ id: 'nan', resetAtMs: Number.NaN },
|
||||
],
|
||||
};
|
||||
expect(collectQuotaRowInstants('claude', quota).map((i) => i.rowId)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
test('drops a reset credit whose expiry will not parse', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [],
|
||||
rateLimitResetCredits: [{ id: 'bad', status: 'available', expiresAt: 'not a date' }],
|
||||
};
|
||||
expect(collectQuotaRowInstants('codex', quota)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSoonestRowId', () => {
|
||||
test('picks the nearest upcoming instant', () => {
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('claude', claudeQuota), NOW)).toBe('five_hour');
|
||||
});
|
||||
|
||||
test('a credit expiring before every window wins the emphasis', () => {
|
||||
const quota = {
|
||||
...codexQuota,
|
||||
windows: [{ id: 'primary', resetAtMs: NOW + 5 * DAY_MS }],
|
||||
};
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('codex', quota), NOW)).toBe('credit-b');
|
||||
});
|
||||
|
||||
test('a window resetting before every credit wins the emphasis', () => {
|
||||
expect(pickSoonestRowId(collectQuotaRowInstants('codex', codexQuota), NOW)).toBe('primary');
|
||||
});
|
||||
|
||||
test('skips instants that have already passed', () => {
|
||||
const instants = [
|
||||
{ rowId: 'past', atMs: NOW - HOUR_MS, kind: 'window' as const },
|
||||
{ rowId: 'exactly-now', atMs: NOW, kind: 'window' as const },
|
||||
{ rowId: 'future', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
];
|
||||
expect(pickSoonestRowId(instants, NOW)).toBe('future');
|
||||
});
|
||||
|
||||
test('returns null when nothing is pending', () => {
|
||||
expect(pickSoonestRowId([], NOW)).toBeNull();
|
||||
expect(pickSoonestRowId([{ rowId: 'past', atMs: NOW - 1, kind: 'window' }], NOW)).toBeNull();
|
||||
});
|
||||
|
||||
test('breaks ties deterministically on row id', () => {
|
||||
const a = [
|
||||
{ rowId: 'b', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
{ rowId: 'a', atMs: NOW + HOUR_MS, kind: 'window' as const },
|
||||
];
|
||||
expect(pickSoonestRowId(a, NOW)).toBe('a');
|
||||
expect(pickSoonestRowId([...a].reverse(), NOW)).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickUrgentRowId', () => {
|
||||
test('highlights only the nearest reset strictly inside the final hour', () => {
|
||||
const instants = [
|
||||
{ rowId: 'later-urgent', atMs: NOW + 45 * 60_000, kind: 'window' as const },
|
||||
{ rowId: 'nearest-urgent', atMs: NOW + 30 * 60_000, kind: 'window' as const },
|
||||
{ rowId: 'past', atMs: NOW - 1, kind: 'window' as const },
|
||||
];
|
||||
|
||||
expect(pickUrgentRowId(instants, NOW)).toBe('nearest-urgent');
|
||||
});
|
||||
|
||||
test('does not highlight at exactly one hour or beyond', () => {
|
||||
expect(
|
||||
pickUrgentRowId(
|
||||
[
|
||||
{ rowId: 'exactly-one-hour', atMs: NOW + HOUR_MS, kind: 'window' },
|
||||
{ rowId: 'later', atMs: NOW + 2 * HOUR_MS, kind: 'window' },
|
||||
],
|
||||
NOW
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetCreditRowId', () => {
|
||||
test('prefers the credit id', () => {
|
||||
expect(resetCreditRowId({ id: 'credit-a', expiresAt: 'x' }, 3)).toBe('credit-a');
|
||||
});
|
||||
|
||||
test('falls back to expiry and index when the payload carries no id', () => {
|
||||
// Must stay byte-identical to CodexQuotaBody's React key.
|
||||
expect(resetCreditRowId({ id: '', expiresAt: '2026-08-13T00:00:00Z' }, 2)).toBe(
|
||||
'2026-08-13T00:00:00Z-2'
|
||||
);
|
||||
expect(resetCreditRowId({ expiresAt: '2026-08-13T00:00:00Z' }, 0)).toBe(
|
||||
'2026-08-13T00:00:00Z-0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextRecoveryMs', () => {
|
||||
test('returns the soonest upcoming instant across windows and credits', () => {
|
||||
expect(nextRecoveryMs('codex', codexQuota, NOW)).toBe(NOW + 3 * HOUR_MS);
|
||||
});
|
||||
|
||||
test('ignores instants already in the past', () => {
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ id: 'stale', resetAtMs: NOW - DAY_MS },
|
||||
{ id: 'live', resetAtMs: NOW + DAY_MS },
|
||||
],
|
||||
};
|
||||
expect(nextRecoveryMs('claude', quota, NOW)).toBe(NOW + DAY_MS);
|
||||
});
|
||||
|
||||
test('is null for an unloaded credential, so sorting can sink it', () => {
|
||||
expect(nextRecoveryMs('claude', undefined, NOW)).toBeNull();
|
||||
expect(nextRecoveryMs('claude', { status: 'idle' }, NOW)).toBeNull();
|
||||
expect(nextRecoveryMs('claude', { status: 'error' }, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
test('is null when every known instant has passed', () => {
|
||||
const quota = { status: 'success', windows: [{ id: 'stale', resetAtMs: NOW - 1 }] };
|
||||
expect(nextRecoveryMs('claude', quota, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
70
frontend/tests/quotaSessionIsolation.test.ts
Normal file
70
frontend/tests/quotaSessionIsolation.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { invalidateAuthFileDerivedCaches } from '../src/features/authFiles/cacheInvalidation';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
commitIfQuotaCacheCurrent,
|
||||
useQuotaStore,
|
||||
} from '../src/stores/useQuotaStore';
|
||||
|
||||
describe('quota cache session isolation', () => {
|
||||
beforeEach(() => {
|
||||
useQuotaStore.getState().clearQuotaCache();
|
||||
});
|
||||
|
||||
test('prevents an earlier connection from committing after the cache is cleared', () => {
|
||||
const previousConnection = captureQuotaCacheGeneration();
|
||||
let committed = false;
|
||||
|
||||
useQuotaStore.getState().clearQuotaCache();
|
||||
|
||||
expect(
|
||||
commitIfQuotaCacheCurrent(previousConnection, () => {
|
||||
committed = true;
|
||||
})
|
||||
).toBe(false);
|
||||
expect(committed).toBe(false);
|
||||
});
|
||||
|
||||
test('allows the current connection generation to commit', () => {
|
||||
const currentConnection = captureQuotaCacheGeneration();
|
||||
let committed = false;
|
||||
|
||||
expect(
|
||||
commitIfQuotaCacheCurrent(currentConnection, () => {
|
||||
committed = true;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(committed).toBe(true);
|
||||
});
|
||||
|
||||
test('clears same-name quota and rejects an in-flight commit after auth mutation', () => {
|
||||
const fileName = 'shared-codex.json';
|
||||
useQuotaStore.getState().setCodexQuota({
|
||||
[fileName]: {
|
||||
status: 'success',
|
||||
windows: [],
|
||||
planType: 'account-a',
|
||||
},
|
||||
});
|
||||
const accountARequest = captureQuotaCacheGeneration();
|
||||
let invalidatedNames: string[] | undefined;
|
||||
|
||||
invalidateAuthFileDerivedCaches(
|
||||
(names) => {
|
||||
invalidatedNames = names;
|
||||
},
|
||||
[fileName]
|
||||
);
|
||||
|
||||
expect(invalidatedNames).toEqual([fileName]);
|
||||
expect(useQuotaStore.getState().codexQuota[fileName]).toBeUndefined();
|
||||
|
||||
let committed = false;
|
||||
expect(
|
||||
commitIfQuotaCacheCurrent(accountARequest, () => {
|
||||
committed = true;
|
||||
})
|
||||
).toBe(false);
|
||||
expect(committed).toBe(false);
|
||||
});
|
||||
});
|
||||
522
frontend/tests/quotaTimeline.test.ts
Normal file
522
frontend/tests/quotaTimeline.test.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
buildTimelineLane,
|
||||
laneHasWindow,
|
||||
pickLaneWindow,
|
||||
projectLane,
|
||||
projectResetCredits,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
timelineSpan,
|
||||
windowsIn,
|
||||
} from '../src/features/quota/quotaTimelineModel';
|
||||
import type { TimelineLane } from '../src/features/quota/quotaTimelineModel';
|
||||
|
||||
const at = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m, d, h, min).getTime();
|
||||
|
||||
describe('windowsIn', () => {
|
||||
test('projects backwards and forwards from the anchor', () => {
|
||||
// Anchor is a known reset; the span opens before the current window did.
|
||||
const anchor = at(2026, 6, 29, 12);
|
||||
const from = anchor - 2.5 * DAY_MS;
|
||||
const to = anchor + 1.5 * DAY_MS;
|
||||
|
||||
const windows = windowsIn(anchor, DAY_MS, from, to);
|
||||
|
||||
// A 4-day span of daily windows needs 5 bars: the span edges fall mid-window,
|
||||
// so there's a partial window at each end.
|
||||
expect(windows.length).toBe(5);
|
||||
// Every boundary sits on a whole period from the anchor.
|
||||
for (const window of windows) {
|
||||
// `+ 0` normalizes JS's -0 from a negative remainder (windows before the anchor).
|
||||
expect(((window.endMs - anchor) % DAY_MS) + 0).toBe(0);
|
||||
expect(window.endMs - window.startMs).toBe(DAY_MS);
|
||||
}
|
||||
expect(windows.some((w) => w.startMs <= anchor && w.endMs >= anchor)).toBe(true);
|
||||
// Fully covers the requested range.
|
||||
expect(windows[0].startMs).toBeLessThanOrEqual(from);
|
||||
expect(windows[windows.length - 1].endMs).toBeGreaterThanOrEqual(to);
|
||||
});
|
||||
|
||||
test('covers the whole span with no gaps or overlaps', () => {
|
||||
const windows = windowsIn(at(2026, 6, 29), 5 * HOUR_MS, at(2026, 6, 28), at(2026, 6, 30));
|
||||
for (let i = 1; i < windows.length; i += 1) {
|
||||
expect(windows[i].startMs).toBe(windows[i - 1].endMs);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a degenerate period, span or anchor', () => {
|
||||
expect(windowsIn(1000, 0, 0, 5000)).toEqual([]);
|
||||
expect(windowsIn(1000, -5, 0, 5000)).toEqual([]);
|
||||
expect(windowsIn(NaN, 1000, 0, 5000)).toEqual([]);
|
||||
expect(windowsIn(1000, 1000, 5000, 5000)).toEqual([]);
|
||||
});
|
||||
|
||||
test('bails out rather than looping forever on an absurd period', () => {
|
||||
// A bad payload could give a 1ms period over a fortnight.
|
||||
expect(windowsIn(0, 1, 0, 14 * DAY_MS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('span boundaries', () => {
|
||||
test('startOfDay and startOfWeek land on local midnight', () => {
|
||||
const mid = at(2026, 6, 29, 14, 37);
|
||||
expect(new Date(startOfDay(mid)).getHours()).toBe(0);
|
||||
expect(new Date(startOfWeek(mid)).getDay()).toBe(0);
|
||||
expect(new Date(startOfWeek(mid)).getHours()).toBe(0);
|
||||
});
|
||||
|
||||
test('weekly span is a fortnight from the containing Sunday', () => {
|
||||
const now = at(2026, 6, 29, 14, 0); // a Wednesday
|
||||
const span = timelineSpan('weekly', 0, now);
|
||||
|
||||
expect(new Date(span.startMs).getDay()).toBe(0);
|
||||
expect(span.days).toBe(14);
|
||||
expect(span.startMs).toBeLessThanOrEqual(now);
|
||||
expect(span.endMs).toBeGreaterThan(now);
|
||||
});
|
||||
|
||||
test('offsets step a week in weekly mode and a day in session mode', () => {
|
||||
const now = at(2026, 6, 29, 14, 0);
|
||||
const weekly = timelineSpan('weekly', 0, now);
|
||||
const weeklyNext = timelineSpan('weekly', 1, now);
|
||||
expect(Math.round((weeklyNext.startMs - weekly.startMs) / DAY_MS)).toBe(7);
|
||||
|
||||
const session = timelineSpan('session', 0, now);
|
||||
const sessionNext = timelineSpan('session', 1, now);
|
||||
expect(Math.round((sessionNext.startMs - session.startMs) / DAY_MS)).toBe(1);
|
||||
expect(session.days).toBe(3);
|
||||
});
|
||||
|
||||
test('spans a whole number of days even across a DST transition', () => {
|
||||
// US DST springs forward 2026-03-08; a fixed +14*DAY_MS would land at 23:00.
|
||||
const span = timelineSpan('weekly', 0, at(2026, 2, 10, 12));
|
||||
expect(new Date(span.startMs).getHours()).toBe(0);
|
||||
expect(new Date(span.endMs).getHours()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectLane', () => {
|
||||
const lane = (over: Partial<TimelineLane> = {}): TimelineLane => ({
|
||||
name: 'a.json',
|
||||
displayName: 'Alice',
|
||||
provider: 'claude',
|
||||
anchorMs: at(2026, 6, 29, 20),
|
||||
periodHours: 24 * 7,
|
||||
remaining: 40,
|
||||
limits: [],
|
||||
resetCredits: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const span = timelineSpan('weekly', 0, at(2026, 6, 29, 12));
|
||||
|
||||
test('classifies past, live and next windows against now', () => {
|
||||
const now = at(2026, 6, 29, 12);
|
||||
const windows = projectLane(lane(), span.startMs, span.endMs, now, 'weekly');
|
||||
|
||||
expect(windows.length).toBeGreaterThan(0);
|
||||
const live = windows.filter((w) => w.state === 'live');
|
||||
expect(live.length).toBe(1);
|
||||
expect(live[0].startMs).toBeLessThanOrEqual(now);
|
||||
expect(live[0].endMs).toBeGreaterThan(now);
|
||||
expect(live[0].remaining).toBe(40);
|
||||
expect(windows.filter((w) => w.state === 'past').every((w) => w.endMs <= now)).toBe(true);
|
||||
expect(windows.filter((w) => w.state === 'next').every((w) => w.startMs > now)).toBe(true);
|
||||
});
|
||||
|
||||
test('does not carry stale remaining usage past the reported reset', () => {
|
||||
const nowAfterReset = at(2026, 6, 30, 12);
|
||||
const windows = projectLane(lane(), span.startMs, span.endMs, nowAfterReset, 'weekly');
|
||||
const live = windows.find((window) => window.state === 'live');
|
||||
|
||||
expect(live).toBeDefined();
|
||||
expect(live?.startMs).toBe(at(2026, 6, 29, 20));
|
||||
expect(live?.remaining).toBeNull();
|
||||
});
|
||||
|
||||
test('clips bars to the visible span', () => {
|
||||
const windows = projectLane(lane(), span.startMs, span.endMs, at(2026, 6, 29, 12), 'weekly');
|
||||
for (const window of windows) {
|
||||
expect(window.leftPercent).toBeGreaterThanOrEqual(0);
|
||||
expect(window.widthPercent).toBeGreaterThan(0);
|
||||
expect(window.leftPercent + window.widthPercent).toBeLessThanOrEqual(100.0001);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns nothing when the lane has no anchor or period', () => {
|
||||
const now = at(2026, 6, 29, 12);
|
||||
expect(projectLane(lane({ anchorMs: null }), span.startMs, span.endMs, now, 'weekly')).toEqual(
|
||||
[]
|
||||
);
|
||||
expect(
|
||||
projectLane(lane({ periodHours: null }), span.startMs, span.endMs, now, 'weekly')
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('session mode projects only real 5-hour windows', () => {
|
||||
const now = at(2026, 6, 29, 12);
|
||||
const sessionSpan = timelineSpan('session', 0, now);
|
||||
|
||||
expect(projectLane(lane(), sessionSpan.startMs, sessionSpan.endMs, now, 'session')).toEqual([]);
|
||||
|
||||
const windows = projectLane(
|
||||
lane({ periodHours: 5 }),
|
||||
sessionSpan.startMs,
|
||||
sessionSpan.endMs,
|
||||
now,
|
||||
'session'
|
||||
);
|
||||
expect(windows.some((w) => w.endMs - w.startMs === 5 * HOUR_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('projects only unexpired reset credits inside the visible span', () => {
|
||||
const now = at(2026, 6, 29, 12);
|
||||
const visibleExpiry = at(2026, 7, 2, 12);
|
||||
const marks = projectResetCredits(
|
||||
lane({
|
||||
resetCredits: [
|
||||
{ id: 'expired', grantedAtMs: null, expiresAtMs: now - HOUR_MS },
|
||||
{ id: 'visible', grantedAtMs: now - DAY_MS, expiresAtMs: visibleExpiry },
|
||||
{ id: 'outside', grantedAtMs: now, expiresAtMs: span.endMs + HOUR_MS },
|
||||
],
|
||||
}),
|
||||
span.startMs,
|
||||
span.endMs,
|
||||
now
|
||||
);
|
||||
|
||||
expect(marks).toHaveLength(1);
|
||||
expect(marks[0].id).toBe('visible');
|
||||
expect(marks[0].leftPercent).toBe(
|
||||
((visibleExpiry - span.startMs) / (span.endMs - span.startMs)) * 100
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickLaneWindow', () => {
|
||||
test('ignores windows with no reset instant', () => {
|
||||
const chosen = pickLaneWindow([
|
||||
{ resetAtMs: null, periodHours: 168 },
|
||||
{ resetAtMs: 1000, periodHours: 5 },
|
||||
{ resetAtMs: undefined, periodHours: 168 },
|
||||
]);
|
||||
expect(chosen?.resetAtMs).toBe(1000);
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug this rule exists for: across a fortnight, the 5-hour window always
|
||||
* resets soonest, so "pick the soonest" drew ~67 slivers per lane instead of
|
||||
* two readable weekly bars.
|
||||
*/
|
||||
test('prefers the longest window that fits the span, not the soonest reset', () => {
|
||||
const fiveHour = { resetAtMs: 1_000, periodHours: 5 };
|
||||
const weekly = { resetAtMs: 9_000, periodHours: 168 };
|
||||
|
||||
expect(pickLaneWindow([fiveHour, weekly], 14 * 24)).toBe(weekly);
|
||||
// A three-day span can't fit a weekly window, so the short one wins.
|
||||
expect(pickLaneWindow([fiveHour, weekly], 3 * 24)).toBe(fiveHour);
|
||||
});
|
||||
|
||||
test('breaks a period tie on the soonest reset', () => {
|
||||
const later = { resetAtMs: 9_000, periodHours: 168 };
|
||||
const sooner = { resetAtMs: 5_000, periodHours: 168 };
|
||||
expect(pickLaneWindow([later, sooner], 14 * 24)).toBe(sooner);
|
||||
});
|
||||
|
||||
test('falls back to the shortest available rather than drawing nothing', () => {
|
||||
// Every window is longer than the span — still better to draw one.
|
||||
const monthly = { resetAtMs: 5_000, periodHours: 720 };
|
||||
expect(pickLaneWindow([monthly], 3 * 24)).toBe(monthly);
|
||||
});
|
||||
|
||||
test('returns null when nothing qualifies', () => {
|
||||
expect(pickLaneWindow([{ resetAtMs: null }])).toBeNull();
|
||||
expect(pickLaneWindow([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTimelineLane', () => {
|
||||
const base = { name: 'a.json', displayName: 'Alice' };
|
||||
|
||||
test('claude/codex: anchors on the span-appropriate window, remaining from used', () => {
|
||||
const soon = at(2026, 6, 29, 20);
|
||||
const later = at(2026, 7, 1, 20);
|
||||
const quota = {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{ label: '7-day', usedPercent: 93, resetAtMs: later, periodHours: 168 },
|
||||
{ label: '5-hour', usedPercent: 20, resetAtMs: soon, periodHours: 5 },
|
||||
],
|
||||
};
|
||||
|
||||
// Fortnight view: the weekly window, even though the 5-hour resets sooner.
|
||||
const weekly = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota,
|
||||
maxPeriodHours: 14 * 24,
|
||||
});
|
||||
expect(weekly.anchorMs).toBe(later);
|
||||
expect(weekly.periodHours).toBe(168);
|
||||
expect(weekly.remaining).toBe(7); // stored USED
|
||||
|
||||
// Three-day view: the weekly window doesn't fit, so the short one is used.
|
||||
const session = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota,
|
||||
maxPeriodHours: 3 * 24,
|
||||
});
|
||||
expect(session.anchorMs).toBe(soon);
|
||||
expect(session.periodHours).toBe(5);
|
||||
expect(session.remaining).toBe(80);
|
||||
|
||||
// Every limit is still summarized in the lane head regardless of the pick.
|
||||
expect(weekly.limits).toEqual([
|
||||
{ label: '7-day', remaining: 7 },
|
||||
{ label: '5-hour', remaining: 80 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('codex: keeps the weekly lane on the account quota instead of Spark quota', () => {
|
||||
const accountReset = at(2026, 7, 1, 20);
|
||||
const sparkReset = at(2026, 6, 29, 20);
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'codex',
|
||||
quota: {
|
||||
status: 'success',
|
||||
windows: [
|
||||
{
|
||||
id: 'weekly',
|
||||
label: 'Weekly limit',
|
||||
usedPercent: 70,
|
||||
resetAtMs: accountReset,
|
||||
periodHours: 168,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-3-codex-spark-weekly-0',
|
||||
label: 'GPT-5.3-Codex-Spark weekly limit',
|
||||
usedPercent: 2,
|
||||
resetAtMs: sparkReset,
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
},
|
||||
maxPeriodHours: 14 * 24,
|
||||
});
|
||||
|
||||
expect(lane.anchorMs).toBe(accountReset);
|
||||
expect(lane.periodHours).toBe(168);
|
||||
expect(lane.remaining).toBe(30);
|
||||
});
|
||||
|
||||
test('codex: includes available reset credits with parseable expiry dates', () => {
|
||||
const expiresAt = '2026-08-02T12:00:00Z';
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'codex',
|
||||
quota: {
|
||||
status: 'success',
|
||||
windows: [{ label: '7-day', usedPercent: 90, resetAtMs: 5000, periodHours: 168 }],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: '2026-07-01T12:00:00Z',
|
||||
expiresAt,
|
||||
},
|
||||
{
|
||||
id: 'spent',
|
||||
status: 'consumed',
|
||||
grantedAt: '2026-07-01T12:00:00Z',
|
||||
expiresAt,
|
||||
},
|
||||
{ id: 'invalid', status: 'available', grantedAt: '', expiresAt: 'not-a-date' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(lane.resetCredits).toEqual([
|
||||
{
|
||||
id: 'credit-1',
|
||||
grantedAtMs: new Date('2026-07-01T12:00:00Z').getTime(),
|
||||
expiresAtMs: new Date(expiresAt).getTime(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('kimi: derives remaining from raw used/limit counts', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'kimi',
|
||||
quota: {
|
||||
status: 'success',
|
||||
rows: [
|
||||
{ label: 'Daily', used: 540, limit: 1000, resetAtMs: 5000, periodHours: 24 },
|
||||
{ label: 'Monthly', used: 8100, limit: 30000, resetAtMs: 9000, periodHours: 720 },
|
||||
],
|
||||
},
|
||||
// A fortnight fits the daily window but not the monthly one.
|
||||
maxPeriodHours: 14 * 24,
|
||||
});
|
||||
|
||||
expect(lane.anchorMs).toBe(5000);
|
||||
expect(lane.remaining).toBe(46);
|
||||
expect(lane.limits).toEqual([
|
||||
{ label: 'Daily', remaining: 46 },
|
||||
{ label: 'Monthly', remaining: 73 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('antigravity anchors on its bucket reset, with remaining from the fraction', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'antigravity',
|
||||
quota: {
|
||||
status: 'success',
|
||||
groups: [
|
||||
{
|
||||
buckets: [
|
||||
{ label: '5h', remainingFraction: 0.4, resetAtMs: 1000, periodHours: 5 },
|
||||
{ label: 'Weekly', remainingFraction: 0.82, resetAtMs: 5000, periodHours: 168 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
maxPeriodHours: 336,
|
||||
});
|
||||
|
||||
// Longest window that fits wins, exactly as it does for claude/codex.
|
||||
expect(lane.anchorMs).toBe(5000);
|
||||
expect(lane.periodHours).toBe(168);
|
||||
// remainingFraction is REMAINING, so it is not inverted.
|
||||
expect(lane.remaining).toBe(82);
|
||||
expect(lane.limits).toEqual([
|
||||
{ label: '5h', remaining: 40 },
|
||||
{ label: 'Weekly', remaining: 82 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('antigravity buckets without a parseable reset do not anchor the lane', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'antigravity',
|
||||
quota: {
|
||||
status: 'success',
|
||||
groups: [{ buckets: [{ label: '5h', remainingFraction: 0.4, resetAtMs: null }] }],
|
||||
},
|
||||
});
|
||||
expect(lane.anchorMs).toBeNull();
|
||||
expect(lane.limits).toEqual([]);
|
||||
});
|
||||
|
||||
test('xai anchors on the weekly limit, with per-product limits', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'xai',
|
||||
quota: {
|
||||
status: 'success',
|
||||
billing: {
|
||||
periodType: 'weekly',
|
||||
usagePercent: 5,
|
||||
resetAtMs: 9000,
|
||||
periodHours: 168,
|
||||
productUsage: [
|
||||
{ product: 'GrokBuild', usagePercent: 5 },
|
||||
{ product: 'GrokChat', usagePercent: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(lane.anchorMs).toBe(9000);
|
||||
expect(lane.periodHours).toBe(168);
|
||||
expect(lane.remaining).toBe(95);
|
||||
// GrokChat has no percentage, so it is not summarized.
|
||||
expect(lane.limits).toEqual([{ label: 'GrokBuild', remaining: 95 }]);
|
||||
});
|
||||
|
||||
test('xai without a weekly limit produces no window at all', () => {
|
||||
// A monthly summary carries periodEnd too, but that is a billing cycle.
|
||||
for (const periodType of ['monthly', 'unknown'] as const) {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'xai',
|
||||
quota: {
|
||||
status: 'success',
|
||||
billing: { periodType, usagePercent: 69, resetAtMs: 9000, periodHours: 720 },
|
||||
},
|
||||
});
|
||||
expect(lane.anchorMs).toBeNull();
|
||||
expect(laneHasWindow(lane)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('xai defaults to a 7-day period when the payload states no start', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'xai',
|
||||
quota: {
|
||||
status: 'success',
|
||||
billing: { periodType: 'weekly', usagePercent: 5, resetAtMs: 9000, periodHours: null },
|
||||
},
|
||||
});
|
||||
expect(lane.periodHours).toBe(24 * 7);
|
||||
});
|
||||
|
||||
test('laneHasWindow drops only lanes that can never draw a bar', () => {
|
||||
const drawable = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota: {
|
||||
status: 'success',
|
||||
windows: [{ label: '7-day', usedPercent: 10, resetAtMs: 5000, periodHours: 168 }],
|
||||
},
|
||||
});
|
||||
expect(laneHasWindow(drawable)).toBe(true);
|
||||
// Unloaded quota has nothing to show yet, so it takes no row.
|
||||
expect(
|
||||
laneHasWindow(buildTimelineLane({ ...base, provider: 'claude', quota: undefined }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('providers with no usable reset produce an empty lane, not a dropped one', () => {
|
||||
// buildTimelineLane always returns a lane; dropping is the caller's job via
|
||||
// laneHasWindow, so the two concerns stay separately testable.
|
||||
for (const provider of ['antigravity', 'xai'] as const) {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider,
|
||||
quota: { status: 'success', groups: [] },
|
||||
});
|
||||
expect(lane.name).toBe('a.json');
|
||||
expect(lane.anchorMs).toBeNull();
|
||||
expect(lane.limits).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('unloaded or errored quota produces an empty lane', () => {
|
||||
expect(
|
||||
buildTimelineLane({ ...base, provider: 'claude', quota: undefined }).anchorMs
|
||||
).toBeNull();
|
||||
expect(
|
||||
buildTimelineLane({ ...base, provider: 'claude', quota: { status: 'error' } }).anchorMs
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('windows without a reset instant do not anchor the lane', () => {
|
||||
const lane = buildTimelineLane({
|
||||
...base,
|
||||
provider: 'claude',
|
||||
quota: {
|
||||
status: 'success',
|
||||
windows: [{ label: '7-day', usedPercent: 50, resetAtMs: null, periodHours: 168 }],
|
||||
},
|
||||
});
|
||||
expect(lane.anchorMs).toBeNull();
|
||||
});
|
||||
});
|
||||
164
frontend/tests/quotaTimelineRendering.test.ts
Normal file
164
frontend/tests/quotaTimelineRendering.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import '../src/i18n/index';
|
||||
import { QuotaTimeline } from '../src/features/quota/components/QuotaTimeline';
|
||||
import type { QuotaFileEntry } from '../src/features/quota/logic';
|
||||
import { buildKimiQuotaRows } from '../src/utils/quota';
|
||||
|
||||
const entries: QuotaFileEntry[] = [
|
||||
{
|
||||
file: { name: 'weekly-only.json', type: 'claude' },
|
||||
type: 'claude',
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
entries,
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light' as const,
|
||||
now: new Date(2026, 6, 29, 12).getTime(),
|
||||
};
|
||||
|
||||
describe('QuotaTimeline rendering', () => {
|
||||
test('shows the selected period date instead of always labelling it Today', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
...baseProps,
|
||||
initialOffset: 1,
|
||||
quotaFor: () => ({
|
||||
status: 'success',
|
||||
windows: [
|
||||
{
|
||||
label: '7-day',
|
||||
usedPercent: 25,
|
||||
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// The next weekly period starts on Sunday 08/02. The button remains the
|
||||
// shortcut back to Today (aria-label/title), but its visible label now
|
||||
// reflects the period selected with the previous/next arrows.
|
||||
expect(markup).toMatch(
|
||||
/<button type="button" aria-label="[^"]+" title="[^"]+">08\/02<\/button>/
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the panel and controls visible when 5-hour mode has no matching lanes', () => {
|
||||
const weeklyOnlyQuota = {
|
||||
status: 'success' as const,
|
||||
windows: [
|
||||
{
|
||||
label: '7-day',
|
||||
usedPercent: 25,
|
||||
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
...baseProps,
|
||||
initialMode: 'session',
|
||||
quotaFor: () => weeklyOnlyQuota,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('<section');
|
||||
expect(markup).toContain('aria-pressed="true"');
|
||||
expect(markup).toContain('role="status"');
|
||||
});
|
||||
|
||||
test('renders a Kimi 5-hour lane from the protobuf-style time unit', () => {
|
||||
const rows = buildKimiQuotaRows({
|
||||
usage: {
|
||||
used: '1',
|
||||
limit: '100',
|
||||
resetTime: '2099-08-06T13:59:23.136523Z',
|
||||
},
|
||||
limits: [
|
||||
{
|
||||
window: { duration: 300, timeUnit: 'TIME_UNIT_MINUTE' },
|
||||
detail: {
|
||||
used: '2',
|
||||
limit: '100',
|
||||
resetTime: '2099-07-31T06:59:23.136523Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
entries: [
|
||||
{
|
||||
file: { name: 'kimi-real-response.json', type: 'kimi' },
|
||||
type: 'kimi',
|
||||
},
|
||||
],
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light',
|
||||
now: new Date('2099-07-31T04:40:00Z').getTime(),
|
||||
initialMode: 'session',
|
||||
quotaFor: () => ({ status: 'success', rows }),
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('kimi-real-response.json');
|
||||
expect(markup).not.toContain('role="status"');
|
||||
});
|
||||
|
||||
test('renders an unexpired Codex reset credit as an expiry tick', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
entries: [
|
||||
{
|
||||
file: { name: 'codex-credit.json', type: 'codex' },
|
||||
type: 'codex',
|
||||
},
|
||||
],
|
||||
displayNameFor: (name: string) => name,
|
||||
resolvedTheme: 'light',
|
||||
now: new Date(2026, 6, 29, 12).getTime(),
|
||||
quotaFor: () => ({
|
||||
status: 'success',
|
||||
windows: [
|
||||
{
|
||||
label: '7-day',
|
||||
usedPercent: 90,
|
||||
resetAtMs: new Date(2026, 7, 1, 12).getTime(),
|
||||
periodHours: 168,
|
||||
},
|
||||
],
|
||||
rateLimitResetCredits: [
|
||||
{
|
||||
id: 'credit-1',
|
||||
status: 'available',
|
||||
grantedAt: '2026-07-20T12:00:00Z',
|
||||
expiresAt: '2026-08-03T12:00:00Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toContain('role="img"');
|
||||
expect(markup).toContain('08/03 12:00');
|
||||
});
|
||||
|
||||
test('stays hidden before any credential exposes a usable quota window', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
createElement(QuotaTimeline, {
|
||||
...baseProps,
|
||||
quotaFor: () => undefined,
|
||||
})
|
||||
);
|
||||
|
||||
expect(markup).toBe('');
|
||||
});
|
||||
});
|
||||
75
frontend/tests/quotaUiState.test.ts
Normal file
75
frontend/tests/quotaUiState.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Session-scoped quota page preferences.
|
||||
*
|
||||
* The merge-on-write case is the one that matters: the tab strip and the sort
|
||||
* control each write a single field and know nothing about the other, so a
|
||||
* whole-object write would make changing a tab silently reset the sort.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { readQuotaUiState, writeQuotaUiState } from '@/features/quota/uiState';
|
||||
|
||||
const KEY = 'quotaPage.uiState';
|
||||
|
||||
/** Test files share one process — leaving a fake `window` behind would leak. */
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
/** The Node test environment has no sessionStorage; a Map-backed stub is enough here. */
|
||||
function installSessionStorage() {
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
};
|
||||
(globalThis as unknown as { window: unknown }).window = { sessionStorage: storage };
|
||||
return storage;
|
||||
}
|
||||
|
||||
let storage: ReturnType<typeof installSessionStorage>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = installSessionStorage();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
describe('quota ui state', () => {
|
||||
test('round-trips both preferences', () => {
|
||||
writeQuotaUiState({ tab: 'codex', sortMode: 'soonest' });
|
||||
expect(readQuotaUiState()).toEqual({ tab: 'codex', sortMode: 'soonest' });
|
||||
});
|
||||
|
||||
test('writing one preference preserves the other', () => {
|
||||
writeQuotaUiState({ sortMode: 'soonest' });
|
||||
writeQuotaUiState({ tab: 'kimi' });
|
||||
|
||||
expect(readQuotaUiState()).toEqual({ tab: 'kimi', sortMode: 'soonest' });
|
||||
});
|
||||
|
||||
test('rejects values that are not part of the current contract', () => {
|
||||
storage.setItem(KEY, JSON.stringify({ tab: 'not-a-tab', sortMode: 'by-vibes' }));
|
||||
expect(readQuotaUiState()).toEqual({ tab: undefined, sortMode: undefined });
|
||||
});
|
||||
|
||||
test('survives absent, malformed, and non-object payloads', () => {
|
||||
expect(readQuotaUiState()).toBeNull();
|
||||
|
||||
storage.setItem(KEY, '{not json');
|
||||
expect(readQuotaUiState()).toBeNull();
|
||||
|
||||
storage.setItem(KEY, '"a string"');
|
||||
expect(readQuotaUiState()).toBeNull();
|
||||
});
|
||||
});
|
||||
126
frontend/tests/sharedClock.test.ts
Normal file
126
frontend/tests/sharedClock.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Shared minute clock: snapshot stability and timer lifecycle.
|
||||
*
|
||||
* The stability case is the important one — a `getSnapshot` that returns a
|
||||
* fresh `Date.now()` makes React's useSyncExternalStore re-render forever.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createSharedClock } from '@/utils/time/sharedClock';
|
||||
|
||||
/** Deterministic stand-in for setInterval: fires only when the test says so. */
|
||||
function makeFakeTimers() {
|
||||
const timers = new Map<number, () => void>();
|
||||
let nextId = 1;
|
||||
let created = 0;
|
||||
let cleared = 0;
|
||||
|
||||
return {
|
||||
created: () => created,
|
||||
cleared: () => cleared,
|
||||
active: () => timers.size,
|
||||
fireAll: () => timers.forEach((fn) => fn()),
|
||||
setTimer: (fn: () => void) => {
|
||||
created += 1;
|
||||
const id = nextId++;
|
||||
timers.set(id, fn);
|
||||
return id;
|
||||
},
|
||||
clearTimer: (id: unknown) => {
|
||||
cleared += 1;
|
||||
timers.delete(id as number);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeClock(startAt = 1_000_000) {
|
||||
const timers = makeFakeTimers();
|
||||
let current = startAt;
|
||||
const clock = createSharedClock({
|
||||
intervalMs: 60_000,
|
||||
now: () => current,
|
||||
setTimer: timers.setTimer,
|
||||
clearTimer: timers.clearTimer,
|
||||
});
|
||||
return { clock, timers, advance: (ms: number) => (current += ms) };
|
||||
}
|
||||
|
||||
describe('createSharedClock', () => {
|
||||
test('getSnapshot is referentially stable until a tick fires', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
clock.subscribe(() => {});
|
||||
|
||||
const first = clock.getSnapshot();
|
||||
advance(30_000);
|
||||
// Wall time moved but no tick fired — the snapshot must not.
|
||||
expect(clock.getSnapshot()).toBe(first);
|
||||
expect(clock.getSnapshot()).toBe(first);
|
||||
|
||||
timers.fireAll();
|
||||
expect(clock.getSnapshot()).toBe(first + 30_000);
|
||||
});
|
||||
|
||||
test('notifies every subscriber on a tick', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
clock.subscribe(() => (a += 1));
|
||||
clock.subscribe(() => (b += 1));
|
||||
|
||||
advance(60_000);
|
||||
timers.fireAll();
|
||||
|
||||
expect(a).toBe(1);
|
||||
expect(b).toBe(1);
|
||||
});
|
||||
|
||||
test('three subscribers share exactly one timer', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
clock.subscribe(() => {});
|
||||
clock.subscribe(() => {});
|
||||
clock.subscribe(() => {});
|
||||
|
||||
expect(clock.subscriberCount()).toBe(3);
|
||||
expect(timers.created()).toBe(1);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
|
||||
test('clears the timer when the last subscriber leaves, and restarts after', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
const off1 = clock.subscribe(() => {});
|
||||
const off2 = clock.subscribe(() => {});
|
||||
|
||||
off1();
|
||||
expect(timers.active()).toBe(1); // still one listener
|
||||
expect(timers.cleared()).toBe(0);
|
||||
|
||||
off2();
|
||||
expect(clock.subscriberCount()).toBe(0);
|
||||
expect(timers.active()).toBe(0);
|
||||
expect(timers.cleared()).toBe(1);
|
||||
|
||||
clock.subscribe(() => {});
|
||||
expect(timers.created()).toBe(2);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
|
||||
test('resynchronizes on the first subscribe so an idle clock is not stale', () => {
|
||||
const { clock, timers, advance } = makeClock();
|
||||
advance(3_600_000); // an hour passes with nobody watching
|
||||
|
||||
clock.subscribe(() => {});
|
||||
expect(clock.getSnapshot()).toBe(1_000_000 + 3_600_000);
|
||||
expect(timers.created()).toBe(1);
|
||||
});
|
||||
|
||||
test('unsubscribing twice does not clear a fresh timer', () => {
|
||||
const { clock, timers } = makeClock();
|
||||
const off = clock.subscribe(() => {});
|
||||
off();
|
||||
off();
|
||||
clock.subscribe(() => {});
|
||||
|
||||
expect(timers.cleared()).toBe(1);
|
||||
expect(timers.active()).toBe(1);
|
||||
});
|
||||
});
|
||||
55
frontend/tests/sponsorAggregation.test.ts
Normal file
55
frontend/tests/sponsorAggregation.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { getSponsorAggregationConflict } from '../src/features/providers/sponsorDefinitions';
|
||||
import type { SponsorProviderRaw } from '../src/features/providers/types';
|
||||
|
||||
const emptyRaw = (): SponsorProviderRaw => ({
|
||||
openai: [],
|
||||
claude: [],
|
||||
codex: [],
|
||||
gemini: [],
|
||||
});
|
||||
|
||||
describe('sponsor aggregation safety', () => {
|
||||
test('detects multiple configs for one protocol', () => {
|
||||
const raw = emptyRaw();
|
||||
raw.codex = [
|
||||
{ index: 0, config: { apiKey: 'first' } },
|
||||
{ index: 1, config: { apiKey: 'second' } },
|
||||
];
|
||||
|
||||
expect(getSponsorAggregationConflict(raw)).toBe('multiple-configs');
|
||||
});
|
||||
|
||||
test('detects multiple OpenAI API keys in one config', () => {
|
||||
const raw = emptyRaw();
|
||||
raw.openai = [
|
||||
{
|
||||
index: 0,
|
||||
config: {
|
||||
name: 'Sponsor',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
apiKeyEntries: [{ apiKey: 'first' }, { apiKey: 'second' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(getSponsorAggregationConflict(raw)).toBe('multiple-openai-keys');
|
||||
});
|
||||
|
||||
test('allows the supported one-config-per-protocol shape', () => {
|
||||
const raw = emptyRaw();
|
||||
raw.codex = [{ index: 0, config: { apiKey: 'codex' } }];
|
||||
raw.openai = [
|
||||
{
|
||||
index: 0,
|
||||
config: {
|
||||
name: 'Sponsor',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
apiKeyEntries: [{ apiKey: 'openai' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(getSponsorAggregationConflict(raw)).toBeNull();
|
||||
});
|
||||
});
|
||||
115
frontend/tests/sponsorCustomEndpoint.test.ts
Normal file
115
frontend/tests/sponsorCustomEndpoint.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { openaiToResource } from '../src/features/providers/adapters';
|
||||
import {
|
||||
buildCode0Raw,
|
||||
CODE0_OPENAI_BASE_URL,
|
||||
CODE0_PROVIDER_NAME,
|
||||
} from '../src/features/providers/code0';
|
||||
import {
|
||||
buildQiniuCloudRaw,
|
||||
QINIU_CLOUD_BASE_URL_OPTIONS,
|
||||
QINIU_CLOUD_PROVIDER_NAME,
|
||||
} from '../src/features/providers/qiniuCloud';
|
||||
import {
|
||||
APIKEY_FUN_OPENAI_BASE_URL,
|
||||
APIKEY_FUN_PROVIDER_NAME,
|
||||
buildApiKeyFunRaw,
|
||||
} from '../src/features/providers/sponsor';
|
||||
import { normalizeConfigResponse } from '../src/services/api/transformers';
|
||||
|
||||
const openAIConfig = (name: string, baseUrl: string) => ({
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name,
|
||||
baseUrl,
|
||||
apiKeyEntries: [{ apiKey: 'test-key' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const customOpenAIConfig = (name: string) => openAIConfig(name, 'https://gateway.example.com/v1');
|
||||
|
||||
const mixedOpenAIConfig = (name: string, officialBaseUrl: string) => ({
|
||||
openaiCompatibility: [
|
||||
{
|
||||
name,
|
||||
baseUrl: officialBaseUrl,
|
||||
apiKeyEntries: [{ apiKey: 'official-key' }],
|
||||
},
|
||||
{
|
||||
name,
|
||||
baseUrl: 'https://gateway.example.com/v1',
|
||||
apiKeyEntries: [{ apiKey: 'custom-key' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('sponsor custom endpoint isolation', () => {
|
||||
test('keeps APIKEY.FUN-named custom endpoints in the generic OpenAI group', () => {
|
||||
expect(buildApiKeyFunRaw(customOpenAIConfig(APIKEY_FUN_PROVIDER_NAME)).openai).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps Code0-named custom endpoints in the generic OpenAI group', () => {
|
||||
expect(buildCode0Raw(customOpenAIConfig(CODE0_PROVIDER_NAME)).openai).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps Qiniu-named custom endpoints in the generic OpenAI group', () => {
|
||||
expect(buildQiniuCloudRaw(customOpenAIConfig(QINIU_CLOUD_PROVIDER_NAME)).openai).toEqual([]);
|
||||
});
|
||||
|
||||
test('keeps same-name custom entries outside sponsor delete targets', () => {
|
||||
expect(
|
||||
buildApiKeyFunRaw(
|
||||
mixedOpenAIConfig(APIKEY_FUN_PROVIDER_NAME, APIKEY_FUN_OPENAI_BASE_URL)
|
||||
).openai.map((item) => item.index)
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
buildCode0Raw(mixedOpenAIConfig(CODE0_PROVIDER_NAME, CODE0_OPENAI_BASE_URL)).openai.map(
|
||||
(item) => item.index
|
||||
)
|
||||
).toEqual([0]);
|
||||
expect(
|
||||
buildQiniuCloudRaw(
|
||||
mixedOpenAIConfig(QINIU_CLOUD_PROVIDER_NAME, QINIU_CLOUD_BASE_URL_OPTIONS[0].openaiBaseUrl)
|
||||
).openai.map((item) => item.index)
|
||||
).toEqual([0]);
|
||||
});
|
||||
|
||||
test('keeps backend indexes when normalization filters an unnamed item', () => {
|
||||
const config = normalizeConfigResponse({
|
||||
'openai-compatibility': [
|
||||
{ 'base-url': 'https://invalid.example.com/v1' },
|
||||
{
|
||||
name: APIKEY_FUN_PROVIDER_NAME,
|
||||
'base-url': APIKEY_FUN_OPENAI_BASE_URL,
|
||||
'api-key-entries': [{ 'api-key': 'official-a' }],
|
||||
},
|
||||
{
|
||||
name: APIKEY_FUN_PROVIDER_NAME,
|
||||
'base-url': 'https://gateway.example.com/v1',
|
||||
'api-key-entries': [{ 'api-key': 'custom-key' }],
|
||||
},
|
||||
{
|
||||
name: APIKEY_FUN_PROVIDER_NAME,
|
||||
'base-url': APIKEY_FUN_OPENAI_BASE_URL,
|
||||
'api-key-entries': [{ 'api-key': 'official-b' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.openaiCompatibility?.map((item) => item.sourceIndex)).toEqual([1, 2, 3]);
|
||||
expect(buildApiKeyFunRaw(config).openai.map((item) => item.index)).toEqual([1, 3]);
|
||||
expect(openaiToResource(config.openaiCompatibility![1], 1).originalIndex).toBe(2);
|
||||
});
|
||||
|
||||
test('still aggregates each sponsor official OpenAI endpoint', () => {
|
||||
expect(
|
||||
buildApiKeyFunRaw(openAIConfig('custom-name', APIKEY_FUN_OPENAI_BASE_URL)).openai.length
|
||||
).toBe(1);
|
||||
expect(buildCode0Raw(openAIConfig('custom-name', CODE0_OPENAI_BASE_URL)).openai.length).toBe(1);
|
||||
expect(
|
||||
buildQiniuCloudRaw(openAIConfig('custom-name', QINIU_CLOUD_BASE_URL_OPTIONS[0].openaiBaseUrl))
|
||||
.openai.length
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
40
frontend/tests/sponsorMutationRecovery.test.ts
Normal file
40
frontend/tests/sponsorMutationRecovery.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
isSponsorPartialMutationError,
|
||||
runSponsorMutationWithRecovery,
|
||||
} from '../src/features/providers/sponsorMutationRecovery';
|
||||
|
||||
describe('sponsor mutation recovery', () => {
|
||||
test('refreshes after a failed multi-endpoint mutation and preserves the original failure', async () => {
|
||||
const originalError = new Error('Claude update failed');
|
||||
const refresh = vi.fn(async () => {});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await runSponsorMutationWithRecovery(async () => {
|
||||
throw originalError;
|
||||
}, refresh);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(isSponsorPartialMutationError(caught)).toBe(true);
|
||||
expect((caught as Error & { cause?: unknown }).cause).toBe(originalError);
|
||||
});
|
||||
|
||||
test('does not let a refresh failure replace the original mutation failure', async () => {
|
||||
const originalError = new Error('OpenAI update failed');
|
||||
|
||||
await expect(
|
||||
runSponsorMutationWithRecovery(
|
||||
async () => {
|
||||
throw originalError;
|
||||
},
|
||||
async () => {
|
||||
throw new Error('refresh failed');
|
||||
}
|
||||
)
|
||||
).rejects.toMatchObject({ cause: originalError });
|
||||
});
|
||||
});
|
||||
38
frontend/tests/thinkingLevels.test.ts
Normal file
38
frontend/tests/thinkingLevels.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildThinkingFromLevels,
|
||||
readThinkingLevels,
|
||||
THINKING_LEVELS,
|
||||
} from '../src/features/providers/thinkingLevels';
|
||||
|
||||
describe('standard thinking level selector', () => {
|
||||
test('only exposes levels recognized by the backend', () => {
|
||||
expect(THINKING_LEVELS).toEqual([
|
||||
'none',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
'auto',
|
||||
]);
|
||||
});
|
||||
|
||||
test('reads standard levels and legacy capability flags', () => {
|
||||
expect(
|
||||
readThinkingLevels({
|
||||
levels: ['LOW', 'custom', 'high', 'none'],
|
||||
zero_allowed: true,
|
||||
dynamic_allowed: true,
|
||||
})
|
||||
).toEqual(['none', 'low', 'high', 'auto']);
|
||||
});
|
||||
|
||||
test('writes canonical backend levels and omits an empty selection', () => {
|
||||
expect(buildThinkingFromLevels([])).toBeUndefined();
|
||||
expect(buildThinkingFromLevels(['auto', 'high', 'none', 'low'])).toEqual({
|
||||
levels: ['low', 'high', 'none', 'auto'],
|
||||
});
|
||||
});
|
||||
});
|
||||
71
frontend/tests/timezoneLabel.test.ts
Normal file
71
frontend/tests/timezoneLabel.test.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Timezone labelling, and the guard against the Asia/Shanghai hardcode
|
||||
* returning.
|
||||
*
|
||||
* Codex reset-credit expiry was rendered in a fixed GMT+8 while every other
|
||||
* timestamp on the same page used the browser's timezone — so one credit
|
||||
* appeared twice, in two timezones, on one screen.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import i18n from '@/i18n';
|
||||
import * as resetCredits from '@/utils/quota/resetCredits';
|
||||
import { formatUtcOffsetLabel, resolveTimeZoneLabel } from '@/utils/time/timezone';
|
||||
|
||||
describe('formatUtcOffsetLabel', () => {
|
||||
test('renders whole-hour offsets east and west of UTC', () => {
|
||||
expect(formatUtcOffsetLabel(480)).toBe('GMT+8');
|
||||
expect(formatUtcOffsetLabel(-300)).toBe('GMT-5');
|
||||
expect(formatUtcOffsetLabel(720)).toBe('GMT+12');
|
||||
expect(formatUtcOffsetLabel(-720)).toBe('GMT-12');
|
||||
});
|
||||
|
||||
test('renders half- and quarter-hour offsets', () => {
|
||||
expect(formatUtcOffsetLabel(330)).toBe('GMT+5:30');
|
||||
expect(formatUtcOffsetLabel(-570)).toBe('GMT-9:30');
|
||||
expect(formatUtcOffsetLabel(345)).toBe('GMT+5:45');
|
||||
});
|
||||
|
||||
test('UTC itself carries no sign', () => {
|
||||
expect(formatUtcOffsetLabel(0)).toBe('GMT');
|
||||
expect(formatUtcOffsetLabel(-0)).toBe('GMT');
|
||||
});
|
||||
|
||||
test('a non-finite offset degrades to a bare GMT rather than "GMT+NaN"', () => {
|
||||
expect(formatUtcOffsetLabel(Number.NaN)).toBe('GMT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimeZoneLabel', () => {
|
||||
test('produces a well-formed label whatever timezone the runner is in', () => {
|
||||
expect(resolveTimeZoneLabel()).toMatch(/^GMT([+-]\d{1,2}(:\d{2})?)?$/);
|
||||
});
|
||||
|
||||
test('reflects the offset of the instant it is given, not of "now"', () => {
|
||||
// A DST-observing zone answers differently in January than in July, so the
|
||||
// label has to follow the date the timestamps are being rendered for.
|
||||
const january = new Date(2026, 0, 15, 12);
|
||||
const july = new Date(2026, 6, 15, 12);
|
||||
expect(resolveTimeZoneLabel(january)).toBe(
|
||||
formatUtcOffsetLabel(-january.getTimezoneOffset())
|
||||
);
|
||||
expect(resolveTimeZoneLabel(july)).toBe(formatUtcOffsetLabel(-july.getTimezoneOffset()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Asia/Shanghai hardcode', () => {
|
||||
test('is gone from the reset-credit module', () => {
|
||||
expect('formatShanghaiDateTime' in resetCredits).toBe(false);
|
||||
expect(resetCredits.normalizeCodexResetCreditsPayload).toBeDefined();
|
||||
});
|
||||
|
||||
test('the expiry heading interpolates a timezone in all four locales', async () => {
|
||||
for (const locale of ['en', 'zh-CN', 'zh-TW', 'ru']) {
|
||||
await i18n.changeLanguage(locale);
|
||||
const label = i18n.t('codex_quota.reset_credits_expiry_label', { timezone: 'GMT+8' });
|
||||
expect(label).toContain('GMT+8');
|
||||
expect(label).not.toContain('{{');
|
||||
}
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
});
|
||||
42
frontend/tests/visualConfigConcurrency.test.ts
Normal file
42
frontend/tests/visualConfigConcurrency.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { useVisualConfig } from '../src/hooks/useVisualConfig';
|
||||
|
||||
describe('visual config concurrency', () => {
|
||||
test('only applies dirty visual fields to the latest server YAML', () => {
|
||||
function Harness() {
|
||||
const visualConfig = useVisualConfig();
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
if (phase === 0) {
|
||||
visualConfig.loadVisualValuesFromYaml(
|
||||
'debug: false\nproxy-url: http://old-proxy.example\n'
|
||||
);
|
||||
setPhase(1);
|
||||
} else if (phase === 1) {
|
||||
visualConfig.setVisualValues({ proxyUrl: 'http://localhost:8080' });
|
||||
setPhase(2);
|
||||
} else {
|
||||
return createElement(
|
||||
'pre',
|
||||
null,
|
||||
visualConfig.applyVisualChangesToYaml(
|
||||
'debug: true\nproxy-url: http://old-proxy.example\n'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(createElement(Harness));
|
||||
const merged = markup.slice('<pre>'.length, -'</pre>'.length);
|
||||
|
||||
expect(parseYaml(merged)).toEqual({
|
||||
debug: true,
|
||||
'proxy-url': 'http://localhost:8080',
|
||||
});
|
||||
});
|
||||
});
|
||||
37
frontend/tests/visualConfigDisableImageGeneration.test.ts
Normal file
37
frontend/tests/visualConfigDisableImageGeneration.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import {
|
||||
parseDisableImageGenerationMode,
|
||||
useVisualConfig,
|
||||
} from '../src/hooks/useVisualConfig';
|
||||
|
||||
describe('visual config disable-image-generation', () => {
|
||||
test('loads and writes the passthrough mode', () => {
|
||||
expect(parseDisableImageGenerationMode('passthrough')).toBe('passthrough');
|
||||
|
||||
function Harness() {
|
||||
const visualConfig = useVisualConfig();
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
if (phase === 0) {
|
||||
visualConfig.setVisualValues({ disableImageGeneration: 'passthrough' });
|
||||
setPhase(1);
|
||||
} else {
|
||||
return createElement(
|
||||
'pre',
|
||||
null,
|
||||
visualConfig.applyVisualChangesToYaml('disable-image-generation: false\n')
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(createElement(Harness));
|
||||
const result = markup.slice('<pre>'.length, -'</pre>'.length);
|
||||
|
||||
expect(parseYaml(result)).toEqual({ 'disable-image-generation': 'passthrough' });
|
||||
});
|
||||
});
|
||||
42
frontend/tests/visualConfigRoutingStrategy.test.ts
Normal file
42
frontend/tests/visualConfigRoutingStrategy.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { createElement, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { parseRoutingStrategy, useVisualConfig } from '../src/hooks/useVisualConfig';
|
||||
|
||||
describe('visual config weighted routing strategy', () => {
|
||||
test('recognizes the weighted-round-robin backend value', () => {
|
||||
expect(parseRoutingStrategy('weighted-round-robin')).toBe('weighted-round-robin');
|
||||
expect(parseRoutingStrategy('weightedroundrobin')).toBe('weighted-round-robin');
|
||||
expect(parseRoutingStrategy('wrr')).toBe('weighted-round-robin');
|
||||
expect(parseRoutingStrategy('fill-first')).toBe('fill-first');
|
||||
expect(parseRoutingStrategy('fillfirst')).toBe('fill-first');
|
||||
expect(parseRoutingStrategy('ff')).toBe('fill-first');
|
||||
expect(parseRoutingStrategy(undefined)).toBe('round-robin');
|
||||
});
|
||||
|
||||
test('writes weighted-round-robin without coercing it to round-robin', () => {
|
||||
function Harness() {
|
||||
const visualConfig = useVisualConfig();
|
||||
const [phase, setPhase] = useState(0);
|
||||
|
||||
if (phase === 0) {
|
||||
visualConfig.setVisualValues({ routingStrategy: 'weighted-round-robin' });
|
||||
setPhase(1);
|
||||
} else {
|
||||
return createElement(
|
||||
'pre',
|
||||
null,
|
||||
visualConfig.applyVisualChangesToYaml('routing:\n strategy: round-robin\n')
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const markup = renderToStaticMarkup(createElement(Harness));
|
||||
const result = markup.slice('<pre>'.length, -'</pre>'.length);
|
||||
|
||||
expect(parseYaml(result)).toEqual({ routing: { strategy: 'weighted-round-robin' } });
|
||||
});
|
||||
});
|
||||
25
frontend/tests/visualConfigValidation.test.ts
Normal file
25
frontend/tests/visualConfigValidation.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { getVisualConfigValidationErrors } from '../src/hooks/useVisualConfig';
|
||||
import { DEFAULT_VISUAL_VALUES } from '../src/types/visualConfig';
|
||||
|
||||
describe('visual config validation', () => {
|
||||
test('requires Redis usage retention to be empty or within 1..3600', () => {
|
||||
const values = structuredClone(DEFAULT_VISUAL_VALUES);
|
||||
|
||||
values.redisUsageQueueRetentionSeconds = '';
|
||||
expect(getVisualConfigValidationErrors(values).redisUsageQueueRetentionSeconds).toBeUndefined();
|
||||
|
||||
values.redisUsageQueueRetentionSeconds = '0';
|
||||
expect(getVisualConfigValidationErrors(values).redisUsageQueueRetentionSeconds).toBe(
|
||||
'integer_range_1_3600'
|
||||
);
|
||||
|
||||
values.redisUsageQueueRetentionSeconds = '3601';
|
||||
expect(getVisualConfigValidationErrors(values).redisUsageQueueRetentionSeconds).toBe(
|
||||
'integer_range_1_3600'
|
||||
);
|
||||
|
||||
values.redisUsageQueueRetentionSeconds = '3600';
|
||||
expect(getVisualConfigValidationErrors(values).redisUsageQueueRetentionSeconds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
149
frontend/tests/xaiApiKeyProvider.test.ts
Normal file
149
frontend/tests/xaiApiKeyProvider.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
import { xaiToResource } from '../src/features/providers/adapters';
|
||||
import { PROVIDER_DESCRIPTORS } from '../src/features/providers/descriptors';
|
||||
import { apiClient } from '../src/services/api/client';
|
||||
import { providersApi } from '../src/services/api/providers';
|
||||
import { normalizeConfigResponse } from '../src/services/api/transformers';
|
||||
|
||||
const originalGet = apiClient.get;
|
||||
const originalPut = apiClient.put;
|
||||
const originalDelete = apiClient.delete;
|
||||
|
||||
afterEach(() => {
|
||||
apiClient.get = originalGet;
|
||||
apiClient.put = originalPut;
|
||||
apiClient.delete = originalDelete;
|
||||
});
|
||||
|
||||
describe('xAI API key provider', () => {
|
||||
test('normalizes the backend xai-api-key contract and exposes a workbench resource', () => {
|
||||
const config = normalizeConfigResponse({
|
||||
'xai-api-key': [
|
||||
{
|
||||
'api-key': 'xai-secret',
|
||||
priority: 7,
|
||||
prefix: 'team-xai',
|
||||
'base-url': 'https://api.x.ai/v1',
|
||||
websockets: true,
|
||||
'proxy-url': 'http://proxy.local',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'grok-4.5', alias: 'grok-latest' }],
|
||||
'excluded-models': ['grok-3-*'],
|
||||
'disable-cooling': true,
|
||||
'auth-index': 'xai:apikey:1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(config.xaiApiKeys).toEqual([
|
||||
{
|
||||
apiKey: 'xai-secret',
|
||||
priority: 7,
|
||||
prefix: 'team-xai',
|
||||
baseUrl: 'https://api.x.ai/v1',
|
||||
websockets: true,
|
||||
proxyUrl: 'http://proxy.local',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [{ name: 'grok-4.5', alias: 'grok-latest' }],
|
||||
excludedModels: ['grok-3-*'],
|
||||
disableCooling: true,
|
||||
authIndex: 'xai:apikey:1',
|
||||
},
|
||||
]);
|
||||
|
||||
const resource = xaiToResource(config.xaiApiKeys![0], 0);
|
||||
expect(resource.brand).toBe('xai');
|
||||
expect(resource.baseUrl).toBe('https://api.x.ai/v1');
|
||||
expect(resource.models).toEqual(['grok-4.5']);
|
||||
expect(resource.flags.websockets).toBe(true);
|
||||
expect(resource.selector).toEqual({
|
||||
brand: 'xai',
|
||||
apiKey: 'xai-secret',
|
||||
baseUrl: 'https://api.x.ai/v1',
|
||||
index: 0,
|
||||
});
|
||||
expect(PROVIDER_DESCRIPTORS.xai.baseUrlRequired).toBe(true);
|
||||
expect(PROVIDER_DESCRIPTORS.xai.supportsWebsockets).toBe(true);
|
||||
});
|
||||
|
||||
test('creates and deletes xAI keys through the backend management contract', async () => {
|
||||
const calls: Array<{ method: string; url: string; data?: unknown }> = [];
|
||||
apiClient.get = (async (url: string) => {
|
||||
calls.push({ method: 'GET', url });
|
||||
return {
|
||||
'xai-api-key': [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://api.x.ai/v1',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
],
|
||||
};
|
||||
}) as typeof apiClient.get;
|
||||
apiClient.put = (async (url: string, data?: unknown) => {
|
||||
calls.push({ method: 'PUT', url, data });
|
||||
return undefined;
|
||||
}) as typeof apiClient.put;
|
||||
apiClient.delete = (async (url: string) => {
|
||||
calls.push({ method: 'DELETE', url });
|
||||
return undefined;
|
||||
}) as typeof apiClient.delete;
|
||||
|
||||
await providersApi.createXAIConfig({
|
||||
apiKey: 'xai-new',
|
||||
priority: 3,
|
||||
prefix: 'xai',
|
||||
baseUrl: 'https://api.x.ai/v1',
|
||||
websockets: true,
|
||||
proxyUrl: 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'grok-4.5',
|
||||
alias: 'grok-latest',
|
||||
thinking: { levels: ['low', 'high', 'xhigh'] },
|
||||
},
|
||||
],
|
||||
excludedModels: ['grok-3-*'],
|
||||
disableCooling: true,
|
||||
});
|
||||
await providersApi.deleteXAIConfig('xai-new', 'https://api.x.ai/v1');
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: 'GET', url: '/config' },
|
||||
{
|
||||
method: 'PUT',
|
||||
url: '/xai-api-key',
|
||||
data: [
|
||||
{
|
||||
'api-key': 'existing',
|
||||
'base-url': 'https://api.x.ai/v1',
|
||||
'future-field': 'preserved',
|
||||
},
|
||||
{
|
||||
'api-key': 'xai-new',
|
||||
priority: 3,
|
||||
prefix: 'xai',
|
||||
'base-url': 'https://api.x.ai/v1',
|
||||
websockets: true,
|
||||
'proxy-url': 'direct',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
models: [
|
||||
{
|
||||
name: 'grok-4.5',
|
||||
alias: 'grok-latest',
|
||||
thinking: { levels: ['low', 'high', 'xhigh'] },
|
||||
},
|
||||
],
|
||||
'excluded-models': ['grok-3-*'],
|
||||
'disable-cooling': true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'DELETE',
|
||||
url: '/xai-api-key?api-key=xai-new&base-url=https%3A%2F%2Fapi.x.ai%2Fv1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
176
frontend/tests/xaiPaidQuotaFallback.test.ts
Normal file
176
frontend/tests/xaiPaidQuotaFallback.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { XAI_CONFIG } from '@/features/quota/providers/xai/data';
|
||||
import { apiCallApi, type ApiCallRequest, type ApiCallResult } from '@/services/api';
|
||||
import {
|
||||
XAI_API_CHAT_URL,
|
||||
XAI_API_ME_URL,
|
||||
XAI_BILLING_MONTHLY_URL,
|
||||
XAI_BILLING_WEEKLY_URL,
|
||||
isPaidXaiAuthFile,
|
||||
} from '@/utils/quota';
|
||||
|
||||
const t = ((key: string) => key) as unknown as TFunction;
|
||||
const originalApiCallRequest = apiCallApi.request;
|
||||
|
||||
const result = (statusCode: number, body: unknown = null): ApiCallResult => ({
|
||||
statusCode,
|
||||
header: {},
|
||||
bodyText: body === null ? '' : JSON.stringify(body),
|
||||
body,
|
||||
});
|
||||
|
||||
const encodeJwt = (payload: Record<string, unknown>): string => {
|
||||
const encoded = btoa(JSON.stringify(payload))
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
return `header.${encoded}.signature`;
|
||||
};
|
||||
|
||||
describe('xAI paid OAuth quota fallback', () => {
|
||||
let requests: ApiCallRequest[];
|
||||
|
||||
beforeEach(() => {
|
||||
requests = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
apiCallApi.request = originalApiCallRequest;
|
||||
});
|
||||
|
||||
test('recognizes paid credentials without trusting individual route hints', () => {
|
||||
expect(isPaidXaiAuthFile({ name: 'paid-route.json', using_api: true, prefix: 'PAID' })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isPaidXaiAuthFile({
|
||||
name: 'tier.json',
|
||||
metadata: { access_token: encodeJwt({ tier: 1 }) },
|
||||
})
|
||||
).toBe(true);
|
||||
expect(isPaidXaiAuthFile({ name: 'using-api-only.json', using_api: true })).toBe(false);
|
||||
expect(isPaidXaiAuthFile({ name: 'prefix-only.json', prefix: 'paid' })).toBe(false);
|
||||
expect(isPaidXaiAuthFile({ name: 'free.json', using_api: false })).toBe(false);
|
||||
expect(isPaidXaiAuthFile({ name: 'free-default.json', base_url: 'https://api.x.ai/v1' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('skips free billing endpoints for a recognized paid list entry', async () => {
|
||||
apiCallApi.request = async (payload) => {
|
||||
requests.push(payload);
|
||||
if (payload.url === XAI_API_ME_URL) {
|
||||
return result(200, { user_id: 'user-1', team_id: 'team-1' });
|
||||
}
|
||||
return result(200, { choices: [] });
|
||||
};
|
||||
|
||||
const summary = await XAI_CONFIG.fetchQuota(
|
||||
{
|
||||
name: 'paid.json',
|
||||
type: 'xai',
|
||||
auth_index: 'xai:1',
|
||||
using_api: true,
|
||||
prefix: 'paid',
|
||||
},
|
||||
t
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([XAI_API_ME_URL, XAI_API_CHAT_URL]);
|
||||
expect(JSON.parse(requests[1]?.data ?? '{}')).toMatchObject({
|
||||
model: 'grok-4.5',
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
});
|
||||
expect(summary).toMatchObject({
|
||||
mode: 'paid-health',
|
||||
source: 'api.x.ai-fallback',
|
||||
planType: 'paid',
|
||||
healthStatus: 'chat-ok',
|
||||
userId: 'user-1',
|
||||
teamId: 'team-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps existing billing behavior when only one route hint is present', async () => {
|
||||
apiCallApi.request = async (payload) => {
|
||||
requests.push(payload);
|
||||
if (payload.url === XAI_BILLING_WEEKLY_URL) {
|
||||
return result(200, {
|
||||
config: {
|
||||
currentPeriod: { type: 'weekly' },
|
||||
creditUsagePercent: 25,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (payload.url === XAI_BILLING_MONTHLY_URL) {
|
||||
return result(200, {
|
||||
config: {
|
||||
monthlyLimit: { val: 10000 },
|
||||
used: { val: 2500 },
|
||||
billingPeriodStart: '2026-07-01T00:00:00Z',
|
||||
billingPeriodEnd: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${payload.url}`);
|
||||
};
|
||||
|
||||
const summary = await XAI_CONFIG.fetchQuota(
|
||||
{ name: 'free.json', type: 'xai', auth_index: 'xai:3', using_api: true },
|
||||
t
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url).sort()).toEqual(
|
||||
[XAI_BILLING_WEEKLY_URL, XAI_BILLING_MONTHLY_URL].sort()
|
||||
);
|
||||
expect(summary).toMatchObject({
|
||||
mode: 'billing',
|
||||
source: 'cli-chat-proxy',
|
||||
periodType: 'weekly',
|
||||
usagePercent: 25,
|
||||
monthlyLimitCents: 10000,
|
||||
billingPeriodEnd: '2026-08-01T00:00:00Z',
|
||||
resetAtMs: null,
|
||||
periodHours: null,
|
||||
});
|
||||
expect(summary.periodStart).toBeUndefined();
|
||||
expect(summary.periodEnd).toBeUndefined();
|
||||
});
|
||||
|
||||
test('falls back to paid health after both free billing probes fail', async () => {
|
||||
apiCallApi.request = async (payload) => {
|
||||
requests.push(payload);
|
||||
if (payload.url === XAI_BILLING_WEEKLY_URL || payload.url === XAI_BILLING_MONTHLY_URL) {
|
||||
return result(403, { error: 'Access denied' });
|
||||
}
|
||||
if (payload.url === XAI_API_ME_URL) return result(200, { user_id: 'paid-user' });
|
||||
return result(200, { choices: [] });
|
||||
};
|
||||
|
||||
const summary = await XAI_CONFIG.fetchQuota(
|
||||
{ name: 'unknown.json', type: 'xai', auth_index: 'xai:4' },
|
||||
t
|
||||
);
|
||||
|
||||
expect(requests.map((request) => request.url)).toEqual([
|
||||
XAI_BILLING_WEEKLY_URL,
|
||||
XAI_BILLING_MONTHLY_URL,
|
||||
XAI_API_ME_URL,
|
||||
XAI_API_CHAT_URL,
|
||||
]);
|
||||
expect(summary).toMatchObject({ mode: 'paid-health', userId: 'paid-user' });
|
||||
});
|
||||
|
||||
test('preserves the billing error when the paid fallback also fails', async () => {
|
||||
apiCallApi.request = async (payload) => {
|
||||
if (payload.url === XAI_API_CHAT_URL) return result(401, { error: 'Invalid token' });
|
||||
return result(403, { error: 'Access denied' });
|
||||
};
|
||||
|
||||
await expect(
|
||||
XAI_CONFIG.fetchQuota({ name: 'invalid.json', type: 'xai', auth_index: 'xai:5' }, t)
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
});
|
||||
32
frontend/tests/xaiUsingApiAuthFile.test.ts
Normal file
32
frontend/tests/xaiUsingApiAuthFile.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
applyAuthFileUsingApi,
|
||||
readAuthFileUsingApi,
|
||||
supportsAuthFileUsingApi,
|
||||
} from '@/features/authFiles/constants';
|
||||
|
||||
describe('xAI auth-file using_api', () => {
|
||||
test('is only exposed for xAI credentials', () => {
|
||||
expect(supportsAuthFileUsingApi('xai')).toBe(true);
|
||||
expect(supportsAuthFileUsingApi('grok')).toBe(true);
|
||||
expect(supportsAuthFileUsingApi('codex')).toBe(false);
|
||||
});
|
||||
|
||||
test('reads boolean-compatible values and defaults to chat-proxy mode', () => {
|
||||
expect(readAuthFileUsingApi({ using_api: true })).toBe(true);
|
||||
expect(readAuthFileUsingApi({ using_api: 'true' })).toBe(true);
|
||||
expect(readAuthFileUsingApi({ using_api: false })).toBe(false);
|
||||
expect(readAuthFileUsingApi({})).toBe(false);
|
||||
});
|
||||
|
||||
test('writes an explicit using_api boolean', () => {
|
||||
expect(applyAuthFileUsingApi({ type: 'xai' }, true)).toEqual({
|
||||
type: 'xai',
|
||||
using_api: true,
|
||||
});
|
||||
expect(applyAuthFileUsingApi({ type: 'xai', using_api: true }, false)).toEqual({
|
||||
type: 'xai',
|
||||
using_api: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue