Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion e2e/daily-task-queue.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { expect, test } from '@playwright/test';

test('minute budget, pause, defer, refresh and evidence-only completion', async ({ page }) => {
test.setTimeout(60000);
await page.setViewportSize({ width: 375, height: 850 });
await page.goto('/dashboard');
await page.locator('main[data-seeded="true"]').waitFor({ timeout: 60000 });
await expect(page.getByTestId('daily-task-queue')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Today' })).toBeVisible();
await expect(page.getByText(/Vocabulary|Reading|Listening|Speaking/).first()).toBeVisible();
await page.getByRole('button', { name: 'Change practice time' }).click();
await page.goto('/settings#practice-time');
await page.getByRole('button', { name: '5 min', exact: true }).click();
await page.goto('/dashboard');
await expect(page.getByTestId('daily-budget')).toHaveText('5');
const row = page.getByTestId('daily-task-row').first();
await row.getByRole('button', { name: /More options for/ }).click();
Expand Down
28 changes: 28 additions & 0 deletions e2e/practice-time-settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';

for (const [locale, heading, minuteLabel, changeLabel] of [
['en-US', 'Practice schedule', '30 min', 'Change practice time'],
['zh-CN', '练习时间安排', '30 分钟', '调整练习时间'],
] as const) {
test.describe(locale, () => {
test.use({ locale });
test('practice time is configured in Settings and drives the dashboard after reload', async ({ page }) => {
await page.goto('/dashboard');
await page.locator('main[data-seeded="true"]').waitFor();
await expect(page.getByTestId('daily-task-queue')).toBeVisible();
await expect(page.getByRole('button', { name: changeLabel })).toHaveCount(0);
await page.goto('/settings#practice-time');
const settings = page.getByTestId('practice-time-settings');
await expect(settings.getByRole('heading', { name: heading })).toBeVisible();
await settings.getByRole('button', { name: minuteLabel, exact: true }).click();
await expect(settings.getByRole('button', { name: minuteLabel, exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.reload();
await expect(settings.getByRole('button', { name: minuteLabel, exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.setViewportSize({ width: 375, height: 850 });
expect(await settings.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
await page.goto('/dashboard');
await expect(page.getByTestId('daily-budget')).toHaveText('30');
await expect(page.getByRole('button', { name: changeLabel })).toHaveCount(0);
});
});
}
37 changes: 37 additions & 0 deletions e2e/selection-translation-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';

async function selectHeading(page: import('@playwright/test').Page) {
await page.goto('/dashboard');
await page.locator('main[data-seeded="true"]').waitFor();
await page.locator('main h1').first().evaluate(element => {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }));
});
}

test('Google rate limit falls back to the configured AI translation', async ({ page }) => {
let calls = 0;
await page.route('**/api/translate/free', route => route.fulfill({ status: 429, json: { code: 'translation_rate_limited', error: 'Google Translate error: 429' } }));
await page.route('**/api/translate', route => { calls++; return route.fulfill({ json: { translation: '欢迎使用 EchoType', pronunciation: 'test' } }); });
await selectHeading(page);
await expect(page.getByText('欢迎使用 EchoType', { exact: true })).toBeVisible();
expect(calls).toBe(1);
await expect(page.getByText('Google Translate error: 429', { exact: true })).toHaveCount(0);
});

test('both translation services unavailable shows a useful localized message', async ({ page }) => {
let recovered = false;
await page.addInitScript(() => localStorage.setItem('echotype_language_settings', JSON.stringify({ interfaceLanguage: 'zh', hasExplicitPreference: true })));
await page.route('**/api/translate/free', route => route.fulfill({ status: 429, json: { code: 'translation_rate_limited', error: 'Google Translate error: 429' } }));
await page.route('**/api/translate', route => recovered ? route.fulfill({ json: { translation: '恢复后的翻译' } }) : route.fulfill({ status: 401, json: { error: 'No API key configured.' } }));
await selectHeading(page);
await expect(page.getByText('免费翻译暂时受到限流,请稍后重试,或前往设置配置 AI 翻译服务。', { exact: true })).toBeVisible();
await expect(page.getByText('Google Translate error: 429', { exact: true })).toHaveCount(0);
recovered = true;
await page.getByRole('button', { name: '重试', exact: true }).click();
await expect(page.getByText('恢复后的翻译', { exact: true })).toBeVisible();
});
3 changes: 3 additions & 0 deletions src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { Suspense, type SyntheticEvent, useCallback, useEffect, useRef, useState
import { OllamaWarningBanner } from '@/components/ollama/ollama-warning-banner';
import { AppearanceSection } from '@/components/settings/appearance-section';
import { LanguageSection } from '@/components/settings/language-section';
import { PracticeTimeSection } from '@/components/settings/practice-time-section';
import { Section } from '@/components/settings/section';
import {
IOS_EYEBROW_CLASS,
Expand Down Expand Up @@ -2209,6 +2210,8 @@ function SettingsContent() {
{/* Language */}
<LanguageSection />

<PracticeTimeSection />

{/* AI Provider */}
<AIProviderSection
setAuthError={setAuthError}
Expand Down
86 changes: 84 additions & 2 deletions src/app/api/translate/free/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const pendingResponses: PendingResponse[] = [];

vi.stubGlobal('fetch', fetchMock);

const { POST } = await import('./route');
let POST: typeof import('./route').POST;

function makeRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/translate/free', {
Expand All @@ -22,7 +22,9 @@ function makeRequest(body: Record<string, unknown>) {
}

describe('POST /api/translate/free', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ POST } = await import('./route'));
fetchMock.mockReset();
pendingResponses.length = 0;
});
Expand Down Expand Up @@ -117,4 +119,84 @@ describe('POST /api/translate/free', () => {
error: 'Google Translate error: 503',
});
});
it('reuses successful translations across requests', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ sentences: [{ trans: '你好' }] })));
await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
const response = await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
expect(await response.json()).toEqual({ translation: '你好', engine: 'google-free' });
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('coalesces identical in-flight requests', async () => {
let resolve: (response: Response) => void = () => {};
fetchMock.mockImplementation(() => new Promise<Response>(done => { resolve = done; }));
const first = POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
const second = POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
await new Promise(done => setImmediate(done));
expect(fetchMock).toHaveBeenCalledTimes(1);
resolve(new Response(JSON.stringify({ sentences: [{ trans: '你好' }] })));
expect((await first).status).toBe(200);
expect((await second).status).toBe(200);
});

it('honors Google rate limiting without repeating requests during Retry-After', async () => {
fetchMock.mockResolvedValue(new Response('Too many requests', { status: 429, headers: { 'Retry-After': '60' } }));
const first = await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
expect(first.status).toBe(429);
expect(first.headers.get('Retry-After')).toBe('60');
expect(await first.json()).toMatchObject({ code: 'translation_rate_limited' });
const second = await POST(makeRequest({ text: 'Another sentence.', targetLang: 'zh-CN' }));
expect(second.status).toBe(429);
expect(fetchMock).toHaveBeenCalledTimes(1);
});

it('bounds concurrent Google requests across separate API calls', async () => {
fetchMock.mockImplementation((url: string) => new Promise<Response>(resolve => { pendingResponses.push({ url, resolve }); }));
const responses = Array.from({ length: 5 }, (_, index) => POST(makeRequest({ text: `Request ${index}`, targetLang: 'zh-CN' })));
await new Promise(done => setImmediate(done));
expect(fetchMock).toHaveBeenCalledTimes(4);
for (const pending of pendingResponses.slice(0, 4)) pending.resolve(new Response(JSON.stringify({ sentences: [{ trans: '译文' }] })));
await new Promise(done => setImmediate(done));
expect(fetchMock).toHaveBeenCalledTimes(5);
pendingResponses[4].resolve(new Response(JSON.stringify({ sentences: [{ trans: '译文' }] })));
expect((await Promise.all(responses)).every(response => response.status === 200)).toBe(true);
});

it('serves cached translations even during rate limiting', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ sentences: [{ trans: '你好' }] })))
.mockResolvedValueOnce(new Response('rate limit', { status: 429 }));
await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
await POST(makeRequest({ text: 'New sentence.', targetLang: 'zh-CN' }));
const cached = await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
expect(cached.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('does not cache empty responses as successful translations', async () => {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ sentences: [] })))
.mockResolvedValueOnce(new Response(JSON.stringify({ sentences: [{ trans: '你好' }] })));
expect((await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }))).status).toBe(502);
expect((await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }))).status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('resumes translation once the rate-limit delay has expired', async () => {
let now = Date.now();
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now);
try {
fetchMock.mockResolvedValueOnce(new Response('rate limit', { status: 429, headers: { 'Retry-After': '1' } }))
.mockResolvedValueOnce(new Response(JSON.stringify({ sentences: [{ trans: '你好' }] })));
expect((await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }))).status).toBe(429);
now += 1001;
expect((await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }))).status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
} finally { clock.mockRestore(); }
});

it('honors a long upstream Retry-After value', async () => {
fetchMock.mockResolvedValue(new Response('rate limit', { status: 429, headers: { 'Retry-After': '3600' } }));
const response = await POST(makeRequest({ text: 'Hello.', targetLang: 'zh-CN' }));
expect(response.headers.get('Retry-After')).toBe('3600');
});

});
146 changes: 105 additions & 41 deletions src/app/api/translate/free/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';

const TRANSLATION_CONCURRENCY = 4;
const CACHE_TTL_MS = 60 * 60 * 1000;
const cache = new Map<string, { translation: string; expiresAt: number }>();
const pending = new Map<string, Promise<string>>();
let rateLimitedUntil = 0;
let activeRequests = 0;
const waiting: Array<() => void> = [];

async function acquireSlot() {
if (activeRequests < TRANSLATION_CONCURRENCY) activeRequests++;
else await new Promise<void>((resolve) => waiting.push(resolve));
return () => {
const next = waiting.shift();
if (next) next();
else activeRequests--;
};
}

function rateLimitError() {
return new UpstreamTranslateError('Free translation is temporarily rate-limited.', 429);
}

class UpstreamTranslateError extends Error {
constructor(
Expand Down Expand Up @@ -28,52 +48,89 @@ export async function POST(req: NextRequest) {
const normalizedTargetLang = targetLang.replace('-', '_').split('_')[0]!;

async function translateChunk(chunk: string): Promise<string> {
const params = new URLSearchParams({
client: 'gtx',
sl: 'en',
tl: normalizedTargetLang,
dt: 't',
dj: '1',
q: chunk,
});

const url = `https://translate.googleapis.com/translate_a/single?${params}`;

let res: Response | undefined;
for (let attempt = 0; attempt < 3; attempt++) {
const key = JSON.stringify([chunk, normalizedTargetLang]);
const cached = cache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.translation;
cache.delete(key);
const existing = pending.get(key);
if (existing) return existing;
if (Date.now() < rateLimitedUntil) throw rateLimitError();

const promise = (async () => {
const release = await acquireSlot();
try {
res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(5000),
if (Date.now() < rateLimitedUntil) throw rateLimitError();
const params = new URLSearchParams({
client: 'gtx',
sl: 'en',
tl: normalizedTargetLang,
dt: 't',
dj: '1',
q: chunk,
});
break;
} catch (err) {
if (attempt === 2) throw err;
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}

if (!res) {
throw new UpstreamTranslateError('Google Translate unreachable after retries', 502);
}

if (!res.ok) {
throw new UpstreamTranslateError(`Google Translate error: ${res.status}`, 502);
}

let data: unknown;
const url = `https://translate.googleapis.com/translate_a/single?${params}`;

let res: Response | undefined;
for (let attempt = 0; attempt < 3; attempt++) {
try {
res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0' },
signal: AbortSignal.timeout(5000),
});
break;
} catch (err) {
if (attempt === 2) throw err;
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}

if (!res) {
throw new UpstreamTranslateError('Google Translate unreachable after retries', 502);
}

if (res.status === 429) {
const retryHeader = res.headers.get('Retry-After');
const seconds =
retryHeader && /^\d+$/.test(retryHeader)
? Number(retryHeader)
: retryHeader
? (Date.parse(retryHeader) - Date.now()) / 1000
: 60;
const retrySeconds = Math.max(1, Number.isFinite(seconds) ? seconds : 60);
rateLimitedUntil = Math.max(rateLimitedUntil, Date.now() + retrySeconds * 1000);
throw rateLimitError();
}
if (!res.ok) {
throw new UpstreamTranslateError(`Google Translate error: ${res.status}`, 502);
}

let data: unknown;
try {
data = await res.json();
} catch {
throw new UpstreamTranslateError('Google Translate returned invalid JSON', 502);
}
const chunkSentences = (data as { sentences?: { trans?: string; orig?: string }[] }).sentences;
const translation =
chunkSentences
?.map((s) => s.trans)
.filter(Boolean)
.join('') || '';
if (!translation.trim()) throw new UpstreamTranslateError('Google Translate returned no translation', 502);
cache.set(key, { translation, expiresAt: Date.now() + CACHE_TTL_MS });
if (cache.size > 500) cache.delete(cache.keys().next().value!);
return translation;
} finally {
release();
}
})();
pending.set(key, promise);
try {
data = await res.json();
} catch {
throw new UpstreamTranslateError('Google Translate returned invalid JSON', 502);
return await promise;
} finally {
pending.delete(key);
}
const chunkSentences = (data as { sentences?: { trans?: string; orig?: string }[] }).sentences;
return (
chunkSentences
?.map((s) => s.trans)
.filter(Boolean)
.join('') || ''
);
}

if (Array.isArray(sentences) && sentences.length > 0) {
Expand Down Expand Up @@ -107,6 +164,13 @@ export async function POST(req: NextRequest) {
});
} catch (error) {
console.error('Free translate error:', error);
if (error instanceof UpstreamTranslateError && error.status === 429) {
const retryAfterSeconds = Math.max(1, Math.ceil((rateLimitedUntil - Date.now()) / 1000));
return NextResponse.json(
{ error: error.message, code: 'translation_rate_limited' },
{ status: 429, headers: { 'Retry-After': String(retryAfterSeconds) } },
);
}
if (error instanceof UpstreamTranslateError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
Expand Down
Loading
Loading