diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 0000000..4e35786
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,60 @@
+name: End-to-End Tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: e2e-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ BUN_VERSION: 1.3.14
+ CI: true
+ NEXT_TELEMETRY_DISABLED: '1'
+
+jobs:
+ playwright:
+ name: Playwright / Chromium
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: ${{ env.BUN_VERSION }}
+
+ - name: Cache Bun package downloads
+ uses: actions/cache@v6
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Install Playwright browsers
+ run: bunx playwright install --with-deps chromium
+
+ - name: Run E2E tests
+ run: bun run test:e2e
+
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ if-no-files-found: ignore
diff --git a/app/account-status/account-status.tsx b/app/account-status/account-status.tsx
new file mode 100644
index 0000000..5fa302d
--- /dev/null
+++ b/app/account-status/account-status.tsx
@@ -0,0 +1,407 @@
+'use client';
+
+import {
+ Alert,
+ Block,
+ Collapse,
+ Empty,
+ Flexbox,
+ SkeletonButton,
+ SkeletonParagraph,
+ SkeletonTags,
+ SkeletonTitle,
+ Tag,
+ Text,
+ Tooltip,
+} from '@lobehub/ui';
+import { Button } from '@lobehub/ui/base-ui';
+import { RefreshCw } from 'lucide-react';
+import { useTranslations } from 'next-intl';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import type { CredentialSummary } from '@/app/credentials/credentials';
+
+interface AccountStatusProps {
+ credentials: CredentialSummary[];
+}
+
+interface AccountStatusSnapshot {
+ checkin: { claimed: boolean | null; message: string | null };
+ credits: {
+ total: number | null;
+ used: number | null;
+ remaining: number | null;
+ plan: string | null;
+ resetAt: string | null;
+ };
+ error: string | null;
+ filename: string;
+ models: string[];
+ queriedAt: string;
+}
+
+const initialSnapshot = (filename: string): AccountStatusSnapshot => ({
+ checkin: { claimed: null, message: null },
+ credits: {
+ total: null,
+ used: null,
+ remaining: null,
+ plan: null,
+ resetAt: null,
+ },
+ error: null,
+ filename,
+ models: [],
+ queriedAt: '',
+});
+
+const unavailableSnapshot = (filename: string): AccountStatusSnapshot => ({
+ ...initialSnapshot(filename),
+ error: 'Credential is unavailable',
+});
+
+const failedSnapshot = (
+ filename: string,
+ error: unknown,
+): AccountStatusSnapshot => ({
+ ...initialSnapshot(filename),
+ error: error instanceof Error ? error.message : 'Account status query failed',
+ queriedAt: new Date().toISOString(),
+});
+
+const quotaPercent = (snapshot: AccountStatusSnapshot): number | null => {
+ const { total, used } = snapshot.credits;
+ if (total === null || total <= 0 || used === null) return null;
+ return Math.min(100, Math.max(0, (used / total) * 100));
+};
+
+const QuotaProgress = ({
+ snapshot,
+ unknownLabel,
+ usedLabel,
+}: {
+ snapshot: AccountStatusSnapshot;
+ unknownLabel: string;
+ usedLabel: (percent: number) => string;
+}) => {
+ const percent = quotaPercent(snapshot);
+ const tone =
+ percent === null
+ ? 'unknown'
+ : percent >= 100
+ ? 'exhausted'
+ : percent >= 80
+ ? 'warning'
+ : 'normal';
+ return (
+
+
+ {percent === null ? '—' : `${percent.toFixed(0)}%`}
+
+ {snapshot.credits.used ?? '—'} / {snapshot.credits.total ?? '—'}
+
+
+
+
+ );
+};
+
+const AccountStatusSkeleton = () => (
+
+
+
+
+
+
+
+);
+
+const AccountStatusCard = ({
+ credential,
+ snapshot,
+ busy,
+ onRefresh,
+ onCheckin,
+}: {
+ credential: CredentialSummary;
+ snapshot: AccountStatusSnapshot;
+ busy: string | null;
+ onRefresh: () => void;
+ onCheckin: () => void;
+}) => {
+ const text = useTranslations('Admin');
+ const percent = quotaPercent(snapshot);
+ const quotaUnknown = text('accountStatus.quotaUnknown');
+ const models = snapshot.models.slice(0, 8);
+ const hasMoreModels = snapshot.models.length > models.length;
+ return (
+
+
+
+ {credential.email || credential.user_id}
+
+
+
+ {credential.filename}
+
+
+
+ {snapshot.error ? : null}
+
+ {text('accountStatus.quota')}
+
+ text('accountStatus.quotaUsed', { percent: value.toFixed(0) })
+ }
+ />
+
+
+ {text('accountStatus.total')}: {snapshot.credits.total ?? '—'}
+
+
+ {text('accountStatus.used')}: {snapshot.credits.used ?? '—'}
+
+
+ {text('accountStatus.remaining')}:{' '}
+ {snapshot.credits.remaining ?? '—'}
+
+
+
+ {text('accountStatus.plan')}: {snapshot.credits.plan ?? '—'}
+
+
+ {text('accountStatus.resetAt')}: {snapshot.credits.resetAt ?? '—'}
+
+ {percent !== null && percent >= 100 ? (
+ {text('accountStatus.exhausted')}
+ ) : null}
+
+
+
+ {text('accountStatus.checkin')}:{' '}
+ {snapshot.checkin.claimed === true
+ ? text('accountStatus.checkedIn')
+ : snapshot.checkin.claimed === false
+ ? text('accountStatus.notCheckedIn')
+ : '—'}
+
+
+
+
+ {text('accountStatus.models')}
+ {models.length ? (
+
+ {snapshot.models.map((model) => (
+ {model}
+ ))}
+
+ ),
+ },
+ ]}
+ variant="borderless"
+ />
+ ) : (
+ {text('accountStatus.noModels')}
+ )}
+
+
+
+ {snapshot.queriedAt
+ ? new Date(snapshot.queriedAt).toLocaleString()
+ : '—'}
+
+
+
+
+ );
+};
+
+const AccountStatus = ({ credentials }: AccountStatusProps) => {
+ const text = useTranslations('Admin');
+ const [snapshots, setSnapshots] = useState<
+ Record
+ >({});
+ const [busy, setBusy] = useState>({});
+ const [page, setPage] = useState(1);
+ const [batchBusy, setBatchBusy] = useState(null);
+ const loadOne = useCallback(
+ async (filename: string, action: 'refresh' | 'checkin' = 'refresh') => {
+ setBusy((current) => ({ ...current, [filename]: action }));
+ try {
+ const response = await fetch('/admin-api/account-status', {
+ body: JSON.stringify({ action, filename }),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ });
+ if (!response.ok) {
+ throw new Error(`Account status request failed (${response.status})`);
+ }
+ const payload = (await response.json()) as {
+ status?: AccountStatusSnapshot;
+ statuses?: AccountStatusSnapshot[];
+ };
+ const snapshot = payload.status ?? payload.statuses?.[0];
+ if (snapshot)
+ setSnapshots((current) => ({ ...current, [filename]: snapshot }));
+ } catch (error) {
+ setSnapshots((current) => ({
+ ...current,
+ [filename]: current[filename] ?? failedSnapshot(filename, error),
+ }));
+ } finally {
+ setBusy((current) => {
+ const next = { ...current };
+ delete next[filename];
+ return next;
+ });
+ }
+ },
+ [],
+ );
+ const loadAll = useCallback(
+ async (action: 'refresh' | 'checkin') => {
+ setBatchBusy(action);
+ try {
+ await Promise.all(
+ credentials
+ .filter((credential) => !credential.is_expired)
+ .map((credential) => loadOne(credential.filename, action)),
+ );
+ } finally {
+ setBatchBusy(null);
+ }
+ },
+ [credentials, loadOne],
+ );
+ useEffect(() => {
+ const timer = window.setTimeout(() => {
+ void loadAll('refresh');
+ }, 0);
+ return () => window.clearTimeout(timer);
+ }, [loadAll]);
+ const pageCredentials = useMemo(
+ () =>
+ credentials.length > 50
+ ? credentials.slice((page - 1) * 12, page * 12)
+ : credentials,
+ [credentials, page],
+ );
+ const pageCount =
+ credentials.length > 50 ? Math.ceil(credentials.length / 12) : 1;
+ return (
+
+
+ {text('accountStatus.title')}
+
+
+
+
+
+ {credentials.length ? (
+ pageCredentials.map((credential) => {
+ const snapshot = snapshots[credential.filename];
+ if (!snapshot && !credential.is_expired) {
+ return ;
+ }
+ return (
+ void loadOne(credential.filename, 'checkin')}
+ onRefresh={() => void loadOne(credential.filename)}
+ />
+ );
+ })
+ ) : (
+
+ )}
+ {pageCount > 1 ? (
+
+
+
+ {page} / {pageCount}
+
+
+
+ ) : null}
+
+ );
+};
+
+export default AccountStatus;
diff --git a/app/account-status/page.tsx b/app/account-status/page.tsx
new file mode 100644
index 0000000..06916f8
--- /dev/null
+++ b/app/account-status/page.tsx
@@ -0,0 +1,15 @@
+import { AdminPage } from '@/app/page';
+import { getAccountStatusCredentials } from '@/lib/server/domain/account-status';
+import AccountStatus from './account-status';
+
+const AccountStatusPage = async () => {
+ const credentials = await getAccountStatusCredentials();
+
+ return (
+
+
+
+ );
+};
+
+export default AccountStatusPage;
diff --git a/app/admin-api/account-status/route.ts b/app/admin-api/account-status/route.ts
new file mode 100644
index 0000000..8ff3afa
--- /dev/null
+++ b/app/admin-api/account-status/route.ts
@@ -0,0 +1,37 @@
+import { getAdminSessionErrorResponse } from '@/lib/server/admin/session';
+import {
+ checkinAccounts,
+ checkinAccount,
+ getAccountStatus,
+ getAccountStatusCredentials,
+} from '@/lib/server/domain/account-status';
+import { getJsonBody } from '@/lib/server/shared/http';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+export const GET = async (request: Request): Promise => {
+ const authError = await getAdminSessionErrorResponse(request);
+ if (authError) return authError;
+ const credentials = await getAccountStatusCredentials();
+ return Response.json({ credentials, statuses: await getAccountStatus() });
+};
+
+export const POST = async (request: Request): Promise => {
+ const authError = await getAdminSessionErrorResponse(request);
+ if (authError) return authError;
+ const body = await getJsonBody<{ action?: unknown; filename?: unknown }>(
+ request,
+ );
+ const filename =
+ typeof body.filename === 'string' ? body.filename.trim() : '';
+ if (body.action === 'checkin') {
+ if (filename) {
+ return Response.json({ status: await checkinAccount(filename) });
+ }
+ return Response.json({ statuses: await checkinAccounts() });
+ }
+ return Response.json({
+ statuses: await getAccountStatus(filename ? [filename] : undefined),
+ });
+};
diff --git a/app/globals.scss b/app/globals.scss
index 5e9dc1a..ed2ab7f 100644
--- a/app/globals.scss
+++ b/app/globals.scss
@@ -266,6 +266,77 @@ textarea {
padding: 24px 32px 48px;
}
+.account-status-progress {
+ display: block;
+ height: 8px;
+ width: 100%;
+ overflow: hidden;
+ border-radius: var(--lobe-border-radius-sm);
+ background: var(--lobe-color-fill-secondary);
+}
+
+.account-status-progress::-webkit-progress-bar {
+ border-radius: inherit;
+ background: var(--lobe-color-fill-secondary);
+}
+
+.account-status-progress::-webkit-progress-value {
+ border-radius: inherit;
+ background: var(--lobe-color-primary);
+ transition: width 180ms ease;
+}
+
+.account-status-progress::-moz-progress-bar {
+ border-radius: inherit;
+ background: var(--lobe-color-primary);
+}
+
+.account-status-progress-warning::-webkit-progress-value {
+ background: var(--lobe-color-warning, #d97706);
+}
+
+.account-status-progress-warning::-moz-progress-bar {
+ background: var(--lobe-color-warning, #d97706);
+}
+
+.account-status-progress-exhausted::-webkit-progress-value {
+ background: var(--lobe-color-error);
+}
+
+.account-status-progress-exhausted::-moz-progress-bar {
+ background: var(--lobe-color-error);
+}
+
+.account-status-progress-unknown::-webkit-progress-value,
+.account-status-progress-unknown::-moz-progress-bar {
+ background: var(--lobe-color-fill);
+}
+
+.account-status-card-busy {
+ opacity: 0.72;
+}
+
+@media (max-width: 767px) {
+ .console-main {
+ padding-inline: 16px;
+ }
+
+ .account-status-quota-values {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .account-status-card button {
+ flex: 1 1 0;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1199px) {
+ .console-main {
+ padding-inline: 24px;
+ }
+}
+
.usage-auto-refresh-control {
align-items: center;
display: inline-flex;
diff --git a/app/page-data.ts b/app/page-data.ts
index f5df143..43ab58f 100644
--- a/app/page-data.ts
+++ b/app/page-data.ts
@@ -8,7 +8,13 @@ import type {
import type { AdminDebugSnapshot } from '@/app/debug/debug';
export type TabKey =
- 'dashboard' | 'usage' | 'credentials' | 'api-test' | 'debug' | 'settings';
+ | 'dashboard'
+ | 'usage'
+ | 'credentials'
+ | 'account-status'
+ | 'api-test'
+ | 'debug'
+ | 'settings';
export interface AdminSettingsSnapshot {
labels: Record;
@@ -35,6 +41,11 @@ export interface CredentialsTabInitialData {
tab: 'credentials';
}
+export interface AccountStatusTabInitialData {
+ credentials: CredentialSummary[];
+ tab: 'account-status';
+}
+
export interface ApiTestInitialData {
credentialModels: Record;
credentials: CredentialSummary[];
@@ -55,6 +66,7 @@ export interface SettingsTabInitialData {
export type AdminConsoleInitialData =
| ApiTestInitialData
+ | AccountStatusTabInitialData
| CredentialsTabInitialData
| DashboardInitialData
| DebugTabInitialData
diff --git a/app/page-loader.ts b/app/page-loader.ts
index 49c439e..4b5d012 100644
--- a/app/page-loader.ts
+++ b/app/page-loader.ts
@@ -141,6 +141,14 @@ export const getInitialData = async ({
tab,
};
}
+ case 'account-status': {
+ const credentials = await listCredentials();
+
+ return {
+ credentials: credentials.credentials as unknown as CredentialSummary[],
+ tab,
+ };
+ }
case 'api-test': {
const eligibleCredentials = await listEligibleCredentialRecords();
const [credentials, currentCredential, models] = await Promise.all([
diff --git a/app/page-shell.tsx b/app/page-shell.tsx
index 8608562..2835e1a 100644
--- a/app/page-shell.tsx
+++ b/app/page-shell.tsx
@@ -72,11 +72,18 @@ const tabs: Array<{
icon: typeof LayoutDashboard;
key: TabKey;
labelKey:
- 'apiTest' | 'credentials' | 'dashboard' | 'debug' | 'settings' | 'usage';
+ | 'apiTest'
+ | 'credentials'
+ | 'dashboard'
+ | 'accountStatus'
+ | 'debug'
+ | 'settings'
+ | 'usage';
}> = [
{ icon: LayoutDashboard, key: 'dashboard', labelKey: 'dashboard' },
{ icon: ChartLine, key: 'usage', labelKey: 'usage' },
{ icon: KeyRound, key: 'credentials', labelKey: 'credentials' },
+ { icon: KeyRound, key: 'account-status', labelKey: 'accountStatus' },
{ icon: Send, key: 'api-test', labelKey: 'apiTest' },
{ icon: Bug, key: 'debug', labelKey: 'debug' },
{ icon: Settings2, key: 'settings', labelKey: 'settings' },
@@ -1913,6 +1920,7 @@ const AdminPageLayoutContent = ({
{children}
) : null}
+ {activeTab === 'account-status' ? children : null}
{activeTab === 'usage' ? (
=4.8.4 <6.1.0" } }, "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ=="],
@@ -2503,6 +2514,8 @@
"vite/esbuild": ["esbuild@0.28.1", "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
+ "vite/fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+
"vite/picomatch": ["picomatch@4.0.5", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"vite/postcss": ["postcss@8.5.16", "https://registry.npmmirror.com/postcss/-/postcss-8.5.16.tgz", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
diff --git a/e2e/account-status.spec.ts b/e2e/account-status.spec.ts
new file mode 100644
index 0000000..fcbad00
--- /dev/null
+++ b/e2e/account-status.spec.ts
@@ -0,0 +1,43 @@
+import { expect, test } from '@playwright/test';
+
+test.describe('Account Status tab', () => {
+ test.beforeEach(async ({ context }) => {
+ await context.addCookies([
+ {
+ name: 'codebuddy2api-locale',
+ value: 'en-US',
+ domain: '127.0.0.1',
+ path: '/',
+ },
+ ]);
+ });
+
+ test('navigates to the tab and shows the empty state', async ({ page }) => {
+ await page.goto('/account-status');
+
+ await expect(
+ page.getByRole('button', { name: 'Account Status' }),
+ ).toBeVisible();
+ await expect(page.getByText('No credentials')).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Refresh all' }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole('button', { name: 'Check in all' }),
+ ).toBeVisible();
+ });
+
+ test('keeps the account status layout usable on mobile', async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 800 });
+ await page.goto('/account-status');
+
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ )
+ .toBe(true);
+ await expect(page.getByText('No credentials')).toBeVisible();
+ });
+});
diff --git a/lib/server/domain/account-status.ts b/lib/server/domain/account-status.ts
new file mode 100644
index 0000000..e10624d
--- /dev/null
+++ b/lib/server/domain/account-status.ts
@@ -0,0 +1,202 @@
+import { getCodeBuddyApiEndpoint } from './config';
+import {
+ listCredentials,
+ listEligibleCredentialRecords,
+ type CredentialRecord,
+} from './credentials';
+import { getModelsForCredential } from '../proxy/codebuddy';
+
+export interface AccountStatusSnapshot {
+ checkin: { claimed: boolean | null; message: string | null };
+ credits: {
+ total: number | null;
+ used: number | null;
+ remaining: number | null;
+ plan: string | null;
+ resetAt: string | null;
+ };
+ error: string | null;
+ filename: string;
+ models: string[];
+ queriedAt: string;
+}
+
+const getBearerToken = (credential: CredentialRecord): string =>
+ String(
+ credential.data.bearer_token ?? credential.data.access_token ?? '',
+ ).trim();
+
+const asRecord = (value: unknown): Record | null =>
+ value && typeof value === 'object'
+ ? (value as Record)
+ : null;
+
+const findValue = (value: unknown, keys: string[]): unknown => {
+ const record = asRecord(value);
+ if (record) {
+ for (const key of keys) {
+ if (record[key] !== undefined && record[key] !== null) return record[key];
+ }
+ for (const nested of Object.values(record)) {
+ const found = findValue(nested, keys);
+ if (found !== undefined) return found;
+ }
+ }
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ const found = findValue(item, keys);
+ if (found !== undefined) return found;
+ }
+ }
+ return undefined;
+};
+
+const toNumber = (value: unknown): number | null => {
+ const number = Number(value);
+ return Number.isFinite(number) ? number : null;
+};
+
+const fetchJson = async (
+ credential: CredentialRecord,
+ path: string,
+ method = 'GET',
+): Promise => {
+ const response = await fetch(new URL(path, await getCodeBuddyApiEndpoint()), {
+ method,
+ headers: {
+ Accept: 'application/json',
+ Authorization: `Bearer ${getBearerToken(credential)}`,
+ },
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!response.ok) throw new Error(`${path} returned ${response.status}`);
+ return response.json();
+};
+
+const loadAccountStatus = async (
+ credential: CredentialRecord,
+): Promise => {
+ const errors: string[] = [];
+ let creditsPayload: unknown;
+ let checkinPayload: unknown;
+ let models: string[] = [];
+
+ try {
+ creditsPayload = await fetchJson(credential, '/api/v2/quota/usage');
+ } catch (error) {
+ errors.push(
+ error instanceof Error ? error.message : 'Credits query failed',
+ );
+ }
+ try {
+ checkinPayload = await fetchJson(
+ credential,
+ '/sash/api/v1/me/daily-check-in/status',
+ );
+ } catch (error) {
+ errors.push(
+ error instanceof Error ? error.message : 'Check-in query failed',
+ );
+ }
+ try {
+ models = (
+ await getModelsForCredential({
+ bearerToken: getBearerToken(credential),
+ credentialData: credential.data,
+ })
+ ).map((model) => model.id);
+ } catch (error) {
+ errors.push(error instanceof Error ? error.message : 'Model query failed');
+ }
+
+ const claimedValue = findValue(checkinPayload, [
+ 'claimed',
+ 'isClaimed',
+ 'checkedIn',
+ 'status',
+ ]);
+ const claimed =
+ typeof claimedValue === 'boolean'
+ ? claimedValue
+ : typeof claimedValue === 'string'
+ ? ['CLAIMED', 'ALREADY_CLAIMED', 'CHECKED_IN'].includes(
+ claimedValue.toUpperCase(),
+ )
+ : null;
+ return {
+ checkin: {
+ claimed,
+ message: typeof claimedValue === 'string' ? claimedValue : null,
+ },
+ credits: {
+ total: toNumber(
+ findValue(creditsPayload, ['total', 'total_size', 'quota']),
+ ),
+ used: toNumber(findValue(creditsPayload, ['used', 'total_used'])),
+ remaining: toNumber(
+ findValue(creditsPayload, ['remaining', 'total_remain']),
+ ),
+ plan:
+ String(
+ findValue(creditsPayload, ['plan', 'planName', 'userType']) ?? '',
+ ) || null,
+ resetAt:
+ String(
+ findValue(creditsPayload, ['resetAt', 'reset_at', 'resetTime']) ?? '',
+ ) || null,
+ },
+ error: errors.length ? errors.join('; ') : null,
+ filename: credential.filename,
+ models,
+ queriedAt: new Date().toISOString(),
+ };
+};
+
+export const getAccountStatus = async (
+ filenames?: string[],
+): Promise => {
+ const credentials = await listEligibleCredentialRecords(filenames);
+ const results: AccountStatusSnapshot[] = [];
+ for (let index = 0; index < credentials.length; index += 4) {
+ const chunk = credentials.slice(index, index + 4);
+ results.push(...(await Promise.all(chunk.map(loadAccountStatus))));
+ }
+ return results;
+};
+
+export const getAccountStatusCredentials = async () => {
+ const response = await listCredentials();
+ return response.credentials;
+};
+
+export const checkinAccount = async (
+ filename: string,
+): Promise => {
+ const credential = (await listEligibleCredentialRecords([filename]))[0];
+ if (!credential) throw new Error('Credential is unavailable');
+ try {
+ await fetchJson(credential, '/sash/api/v1/me/daily-check-in/claim', 'POST');
+ } catch (error) {
+ return {
+ ...(await loadAccountStatus(credential)),
+ error: error instanceof Error ? error.message : 'Check-in failed',
+ };
+ }
+ return loadAccountStatus(credential);
+};
+
+export const checkinAccounts = async (
+ filenames?: string[],
+): Promise => {
+ const credentials = await listEligibleCredentialRecords(filenames);
+ const results: AccountStatusSnapshot[] = [];
+ for (let index = 0; index < credentials.length; index += 4) {
+ const chunk = credentials.slice(index, index + 4);
+ results.push(
+ ...(await Promise.all(
+ chunk.map((credential) => checkinAccount(credential.filename)),
+ )),
+ );
+ }
+ return results;
+};
diff --git a/lib/server/proxy/codebuddy.ts b/lib/server/proxy/codebuddy.ts
index 50d49a6..7f1274a 100644
--- a/lib/server/proxy/codebuddy.ts
+++ b/lib/server/proxy/codebuddy.ts
@@ -2203,10 +2203,7 @@ export const getModelsForCredential = async ({
bearerToken: string;
credentialData: CredentialData;
}): Promise => {
- const endpoint = new URL(
- '/console/enterprises/personal/models',
- await getCodeBuddyApiEndpoint(),
- );
+ const apiEndpoint = await getCodeBuddyApiEndpoint();
const headers = new Headers({
Accept: 'application/json',
Authorization: `Bearer ${bearerToken}`,
@@ -2232,10 +2229,16 @@ export const getModelsForCredential = async ({
headers.set('X-Tenant-Id', String(tenantId));
}
- const response = await fetch(endpoint, {
- headers,
- signal: AbortSignal.timeout(15_000),
- });
+ const fetchModels = async (path: string): Promise =>
+ fetch(new URL(path, apiEndpoint), {
+ headers,
+ signal: AbortSignal.timeout(15_000),
+ });
+ let response = await fetchModels('/v3/config');
+
+ if (response.status === 404 || response.status === 405) {
+ response = await fetchModels('/console/enterprises/personal/models');
+ }
if (!response.ok) {
throw new Error(`Model discovery failed with status ${response.status}`);
diff --git a/messages/en-US.json b/messages/en-US.json
index 926ac67..72f88c2 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -25,10 +25,35 @@
"dashboard": "Dashboard",
"usage": "Usage",
"credentials": "Credentials",
+ "accountStatus": "Account Status",
"apiTest": "API Test",
"debug": "Debug",
"settings": "Settings"
},
+ "accountStatus": {
+ "title": "Account Status",
+ "quota": "Quota",
+ "total": "Total",
+ "used": "Used",
+ "remaining": "Remaining",
+ "plan": "Plan",
+ "resetAt": "Reset time",
+ "exhausted": "Quota exhausted",
+ "checkin": "Check-in",
+ "checkedIn": "Checked in today",
+ "notCheckedIn": "Not checked in today",
+ "checkinAction": "Check in",
+ "models": "Available models",
+ "showModels": "Show all ({count})",
+ "modelCount": "Models ({count})",
+ "noModels": "No models",
+ "refresh": "Refresh",
+ "refreshAll": "Refresh all",
+ "checkinAll": "Check in all",
+ "empty": "No credentials",
+ "quotaUnknown": "Unknown quota",
+ "quotaUsed": "{percent}% quota used"
+ },
"dashboard": {
"welcomeTitle": "Ready to crank through some tokens?",
"active": "{count} active",
diff --git a/messages/ja-JP.json b/messages/ja-JP.json
index 4a41dfc..c94ee0f 100644
--- a/messages/ja-JP.json
+++ b/messages/ja-JP.json
@@ -25,10 +25,35 @@
"dashboard": "ダッシュボード",
"usage": "使用量",
"credentials": "認証情報",
+ "accountStatus": "アカウント状態",
"apiTest": "API テスト",
"debug": "Debug",
"settings": "設定"
},
+ "accountStatus": {
+ "title": "アカウント状態",
+ "quota": "使用量",
+ "total": "合計",
+ "used": "使用済み",
+ "remaining": "残り",
+ "plan": "プラン",
+ "resetAt": "リセット時刻",
+ "exhausted": "使用量上限に達しました",
+ "checkin": "チェックイン",
+ "checkedIn": "本日はチェックイン済み",
+ "notCheckedIn": "本日は未チェックイン",
+ "checkinAction": "チェックイン",
+ "models": "利用可能なモデル",
+ "showModels": "すべて表示({count})",
+ "modelCount": "モデル({count})",
+ "noModels": "モデルなし",
+ "refresh": "更新",
+ "refreshAll": "すべて更新",
+ "checkinAll": "すべてチェックイン",
+ "empty": "認証情報がありません",
+ "quotaUnknown": "使用量不明",
+ "quotaUsed": "使用量 {percent}%"
+ },
"dashboard": {
"welcomeTitle": "トークンをぶん回す準備はできた?",
"active": "{count} 件が有効",
diff --git a/messages/zh-CN.json b/messages/zh-CN.json
index 71db5c9..e423b8d 100644
--- a/messages/zh-CN.json
+++ b/messages/zh-CN.json
@@ -25,10 +25,35 @@
"dashboard": "仪表板",
"usage": "用量统计",
"credentials": "凭证管理",
+ "accountStatus": "账号状态",
"apiTest": "API 测试",
"debug": "Debug",
"settings": "设置"
},
+ "accountStatus": {
+ "title": "账号状态",
+ "quota": "额度",
+ "total": "总额",
+ "used": "已使用",
+ "remaining": "剩余",
+ "plan": "套餐",
+ "resetAt": "重置时间",
+ "exhausted": "额度已用尽",
+ "checkin": "签到",
+ "checkedIn": "今日已签到",
+ "notCheckedIn": "今日未签到",
+ "checkinAction": "签到",
+ "models": "可用模型",
+ "showModels": "展开全部({count} 个)",
+ "modelCount": "模型({count} 个)",
+ "noModels": "暂无模型",
+ "refresh": "刷新",
+ "refreshAll": "刷新全部",
+ "checkinAll": "全部签到",
+ "empty": "暂无凭据",
+ "quotaUnknown": "额度未知",
+ "quotaUsed": "已使用 {percent}% 额度"
+ },
"dashboard": {
"welcomeTitle": "做好蹬的准备了吗?",
"active": "{count} 个有效",
diff --git a/next-env.d.ts b/next-env.d.ts
index 9edff1c..ce4e94a 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,6 +1,7 @@
///
///
import "./.next/types/routes.d.ts";
+import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/package.json b/package.json
index 65e0be1..4c86621 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"dev": "next dev",
"prepare": "husky",
"build": "next build",
+ "test:e2e": "playwright test",
"start": "next start --hostname 0.0.0.0 --port 8001",
"lint": "eslint . --ext .ts,.tsx --max-warnings=0",
"format": "prettier --write .",
@@ -44,6 +45,7 @@
"@commitlint/types": "^21.2.0",
"@eslint/compat": "^2.1.0",
"@next/eslint-plugin-next": "^16.3.3",
+ "@playwright/test": "^1.55.0",
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..cc93bb2
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ reporter: [['list'], ['html', { open: 'never' }]],
+ use: {
+ baseURL: 'http://127.0.0.1:8001',
+ trace: 'retain-on-failure',
+ },
+ webServer: {
+ command: 'bun run dev -- --hostname 127.0.0.1 --port 8001',
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ url: 'http://127.0.0.1:8001/health',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+});
diff --git a/tests/admin/page-loader.test.ts b/tests/admin/page-loader.test.ts
index f3ce968..3720299 100644
--- a/tests/admin/page-loader.test.ts
+++ b/tests/admin/page-loader.test.ts
@@ -106,6 +106,7 @@ describe('tab-scoped initial data', () => {
'credentials',
['listAccessKeys', 'listCredentials', 'getCurrentCredentialInfo'],
],
+ ['account-status', ['listCredentials']],
[
'api-test',
[
@@ -123,18 +124,19 @@ describe('tab-scoped initial data', () => {
const initialData = await getInitialData({ locale: 'en-US', tab });
expect(initialData.tab).toBe(tab);
+ const forbiddenKeys = [
+ 'accessKeys',
+ 'apiEndpoint',
+ 'credentials',
+ 'currentCredential',
+ 'debug',
+ 'health',
+ 'settings',
+ 'stats',
+ 'usage',
+ ].filter((key) => !(tab === 'account-status' && key === 'credentials'));
expect(Object.keys(initialData).sort()).not.toEqual(
- expect.arrayContaining([
- 'accessKeys',
- 'apiEndpoint',
- 'credentials',
- 'currentCredential',
- 'debug',
- 'health',
- 'settings',
- 'stats',
- 'usage',
- ]),
+ expect.arrayContaining(forbiddenKeys),
);
for (const [name, loader] of Object.entries(domainLoaders)) {
diff --git a/tests/server/account-status-route.test.ts b/tests/server/account-status-route.test.ts
new file mode 100644
index 0000000..6c52072
--- /dev/null
+++ b/tests/server/account-status-route.test.ts
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/server/admin/session', () => ({
+ getAdminSessionErrorResponse: vi.fn(),
+}));
+vi.mock('@/lib/server/domain/account-status', () => ({
+ checkinAccount: vi.fn(),
+ checkinAccounts: vi.fn(),
+ getAccountStatus: vi.fn(),
+ getAccountStatusCredentials: vi.fn(),
+}));
+
+const { getAdminSessionErrorResponse } =
+ await import('@/lib/server/admin/session');
+const {
+ checkinAccount,
+ checkinAccounts,
+ getAccountStatus,
+ getAccountStatusCredentials,
+} = await import('@/lib/server/domain/account-status');
+const { GET, POST } = await import('@/app/admin-api/account-status/route');
+
+const request = (body?: unknown): Request =>
+ new Request('http://localhost/admin-api/account-status', {
+ ...(body === undefined
+ ? {}
+ : {
+ body: JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ }),
+ });
+
+describe('account status admin route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getAdminSessionErrorResponse).mockResolvedValue(null);
+ vi.mocked(getAccountStatusCredentials).mockResolvedValue([] as never);
+ vi.mocked(getAccountStatus).mockResolvedValue([]);
+ vi.mocked(checkinAccounts).mockResolvedValue([]);
+ vi.mocked(checkinAccount).mockResolvedValue({} as never);
+ });
+
+ it('requires an administrator session', async () => {
+ const denied = Response.json({ error: 'unauthorized' }, { status: 401 });
+ vi.mocked(getAdminSessionErrorResponse)
+ .mockResolvedValueOnce(denied)
+ .mockResolvedValueOnce(denied);
+
+ expect((await GET(request())).status).toBe(401);
+ expect((await POST(request({ action: 'refresh' }))).status).toBe(401);
+ });
+
+ it('returns credentials and statuses on GET', async () => {
+ vi.mocked(getAccountStatusCredentials).mockResolvedValueOnce([
+ { filename: 'one.json' },
+ ] as never);
+ vi.mocked(getAccountStatus).mockResolvedValueOnce([
+ { filename: 'one.json' } as never,
+ ]);
+
+ const payload = await (await GET(request())).json();
+ expect(payload).toEqual({
+ credentials: [{ filename: 'one.json' }],
+ statuses: [{ filename: 'one.json' }],
+ });
+ });
+
+ it('supports refresh and single or batch check-in actions', async () => {
+ await POST(request({ action: 'refresh', filename: ' one.json ' }));
+ expect(getAccountStatus).toHaveBeenCalledWith(['one.json']);
+
+ await POST(request({ action: 'checkin', filename: 'one.json' }));
+ expect(checkinAccount).toHaveBeenCalledWith('one.json');
+
+ await POST(request({ action: 'checkin' }));
+ expect(checkinAccounts).toHaveBeenCalledWith();
+
+ await POST(request({ action: 'unknown', filename: 42 }));
+ expect(getAccountStatus).toHaveBeenCalledWith(undefined);
+ });
+});
diff --git a/tests/server/account-status.test.ts b/tests/server/account-status.test.ts
new file mode 100644
index 0000000..52c454c
--- /dev/null
+++ b/tests/server/account-status.test.ts
@@ -0,0 +1,211 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('@/lib/server/domain/config', () => ({
+ getCodeBuddyApiEndpoint: vi.fn(),
+}));
+vi.mock('@/lib/server/domain/credentials', () => ({
+ listCredentials: vi.fn(),
+ listEligibleCredentialRecords: vi.fn(),
+}));
+vi.mock('@/lib/server/proxy/codebuddy', () => ({
+ getModelsForCredential: vi.fn(),
+}));
+
+const { getCodeBuddyApiEndpoint } = await import('@/lib/server/domain/config');
+const { listCredentials, listEligibleCredentialRecords } =
+ await import('@/lib/server/domain/credentials');
+const { getModelsForCredential } = await import('@/lib/server/proxy/codebuddy');
+const {
+ checkinAccount,
+ checkinAccounts,
+ getAccountStatus,
+ getAccountStatusCredentials,
+} = await import('@/lib/server/domain/account-status');
+
+const credential = (filename: string) => ({
+ data: { bearer_token: `token-${filename}` },
+ filePath: `/tmp/${filename}`,
+ filename,
+});
+const jsonResponse = (payload: unknown, status = 200) =>
+ new Response(JSON.stringify(payload), {
+ headers: { 'Content-Type': 'application/json' },
+ status,
+ });
+
+describe('account status domain', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(getCodeBuddyApiEndpoint).mockResolvedValue(
+ 'https://codebuddy.example.test',
+ );
+ vi.mocked(listEligibleCredentialRecords).mockResolvedValue([
+ credential('one.json'),
+ ] as never);
+ vi.mocked(listCredentials).mockResolvedValue({ credentials: [] } as never);
+ vi.mocked(getModelsForCredential).mockResolvedValue([
+ { displayName: 'Model One', id: 'model-one' },
+ ]);
+ });
+
+ it('normalizes quota, check-in, and models', async () => {
+ const fetchMock = vi
+ .spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ jsonResponse({
+ userQuota: { total: 1000, used: 250, remaining: 750, plan: 'Pro' },
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ status: 'CLAIMED' }));
+ const [result] = await getAccountStatus();
+ expect(result).toMatchObject({
+ credits: { total: 1000, used: 250, remaining: 750, plan: 'Pro' },
+ checkin: { claimed: true },
+ models: ['model-one'],
+ error: null,
+ });
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('records partial upstream errors', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse({ userQuota: { total: 0 } }))
+ .mockResolvedValueOnce(jsonResponse({}, 404));
+ vi.mocked(getModelsForCredential).mockRejectedValueOnce(
+ new Error('models unavailable'),
+ );
+ const [result] = await getAccountStatus();
+ expect(result.credits.total).toBe(0);
+ expect(result.error).toContain('returned 404');
+ expect(result.error).toContain('models unavailable');
+ });
+
+ it('keeps unsupported quota and check-in values unknown', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ jsonResponse({
+ data: [
+ {
+ limits: {
+ planName: 'Team',
+ quota: 'not-a-number',
+ reset_at: 'tomorrow',
+ total_remain: '3',
+ total_used: '2',
+ },
+ },
+ ],
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ status: 'PENDING' }));
+
+ const [result] = await getAccountStatus();
+
+ expect(result).toMatchObject({
+ checkin: { claimed: false, message: 'PENDING' },
+ credits: {
+ plan: 'Team',
+ remaining: 3,
+ resetAt: 'tomorrow',
+ total: null,
+ used: 2,
+ },
+ });
+ });
+
+ it('searches nested arrays and supports access-token credentials', async () => {
+ vi.mocked(listEligibleCredentialRecords).mockResolvedValueOnce([
+ {
+ data: { access_token: 'access-token-only' },
+ filePath: '/tmp/array.json',
+ filename: 'array.json',
+ },
+ ] as never);
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ jsonResponse({ items: [{ total: 12, used: 4, remaining: 8 }] }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ items: [{ claimed: true }] }));
+
+ const [result] = await getAccountStatus();
+
+ expect(result.credits).toMatchObject({ total: 12, used: 4, remaining: 8 });
+ expect(result.checkin.claimed).toBe(true);
+ });
+
+ it('returns the configured credential summaries', async () => {
+ vi.mocked(listCredentials).mockResolvedValueOnce({
+ credentials: [{ filename: 'summary.json' }],
+ } as never);
+
+ await expect(getAccountStatusCredentials()).resolves.toEqual([
+ { filename: 'summary.json' },
+ ]);
+ });
+
+ it('handles non-Error upstream failures without discarding other results', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockRejectedValueOnce('quota unavailable')
+ .mockResolvedValueOnce(jsonResponse({ checkedIn: false }));
+ vi.mocked(getModelsForCredential).mockRejectedValueOnce(
+ 'models unavailable',
+ );
+
+ const [result] = await getAccountStatus();
+
+ expect(result.checkin.claimed).toBe(false);
+ expect(result.error).toContain('Credits query failed');
+ expect(result.error).toContain('Model query failed');
+ });
+
+ it('checks in and refreshes one account', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(
+ jsonResponse({ userQuota: { total: 10, used: 2, remaining: 8 } }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ claimed: true }));
+ const result = await checkinAccount('one.json');
+ expect(result.credits.remaining).toBe(8);
+ });
+
+ it('returns a refreshed error snapshot when check-in fails', async () => {
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse({}, 503))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ userQuota: { quota: 3, total_remain: 2, total_used: 1 },
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse({ isClaimed: false }));
+
+ const result = await checkinAccount('one.json');
+
+ expect(result.error).toContain('claim returned 503');
+ expect(result.credits).toMatchObject({ remaining: 2, total: 3, used: 1 });
+ });
+
+ it('rejects a check-in request for a missing credential', async () => {
+ vi.mocked(listEligibleCredentialRecords).mockResolvedValueOnce([] as never);
+
+ await expect(checkinAccount('missing.json')).rejects.toThrow(
+ 'Credential is unavailable',
+ );
+ });
+
+ it('processes all batch accounts', async () => {
+ const records = Array.from({ length: 5 }, (_, index) =>
+ credential(`credential-${index}.json`),
+ );
+ vi.mocked(listEligibleCredentialRecords).mockResolvedValue(
+ records as never,
+ );
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) =>
+ init?.method === 'POST'
+ ? jsonResponse({ success: true })
+ : jsonResponse({ userQuota: { total: 1, used: 0, remaining: 1 } }),
+ );
+ const results = await checkinAccounts();
+ expect(results).toHaveLength(5);
+ });
+});
diff --git a/tests/server/units.test.ts b/tests/server/units.test.ts
index 46d90cf..216003e 100644
--- a/tests/server/units.test.ts
+++ b/tests/server/units.test.ts
@@ -4233,6 +4233,15 @@ describe('server units', () => {
.mockResolvedValueOnce(
new Response(JSON.stringify({ code: 0, data: { models: [] } })),
)
+ .mockResolvedValueOnce(new Response(null, { status: 404 }))
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ code: 0,
+ data: { models: [{ id: 'fallback-model', name: 'Fallback' }] },
+ }),
+ ),
+ )
.mockRejectedValue(new Error('Upstream unavailable'));
await expect(
@@ -4261,6 +4270,9 @@ describe('server units', () => {
await expect(
getModelsForCredential({ bearerToken: 'token-d', credentialData: {} }),
).resolves.toEqual([]);
+ await expect(
+ getModelsForCredential({ bearerToken: 'token-e', credentialData: {} }),
+ ).resolves.toEqual([]);
const records = [
{
diff --git a/vitest.config.ts b/vitest.config.ts
index 68dace4..2ae9e34 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -25,7 +25,13 @@ const vitestConfig = defineConfig({
clearMocks: true,
setupFiles: ['./vitest.setup.ts'],
testTimeout: 15_000,
- exclude: ['.next/**', 'coverage/**', 'dist/**', 'node_modules/**'],
+ exclude: [
+ '.next/**',
+ 'coverage/**',
+ 'dist/**',
+ 'e2e/**',
+ 'node_modules/**',
+ ],
coverage: {
provider: 'v8',
include: ['lib/server/**/*.ts'],