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
6 changes: 3 additions & 3 deletions e2e/chat-agent-upgrade.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async function preparePage(page: Page) {
await page.evaluate(
async ({ contents, sessions, records }) => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('echotype');
const request = indexedDB.open('echotype:anonymous');
request.onsuccess = () => {
const db = request.result;
const storeNames = ['contents', 'records', 'sessions', 'books', 'conversations'].filter((name) =>
Expand Down Expand Up @@ -215,7 +215,7 @@ async function waitForContentInDb(page: Page, matcher: string) {
.poll(async () => {
return page.evaluate(async (query) => {
return await new Promise<boolean>((resolve, reject) => {
const request = indexedDB.open('echotype');
const request = indexedDB.open('echotype:anonymous');
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('contents', 'readonly');
Expand Down Expand Up @@ -252,7 +252,7 @@ async function waitForAssessmentLevel(page: Page, expectedLevel: string) {
async function seedConversationHistory(page: Page, count: number) {
await page.evaluate(async (conversationCount) => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('echotype');
const request = indexedDB.open('echotype:anonymous');
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('conversations', 'readwrite');
Expand Down
2 changes: 1 addition & 1 deletion e2e/chat-content-exercises.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ async function seedContent(page: Page) {
];

// Open the echotype DB at whatever version Dexie created it
const req = indexedDB.open('echotype');
const req = indexedDB.open('echotype:anonymous');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('contents')) {
Expand Down
2 changes: 1 addition & 1 deletion e2e/chat-smart-assistant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ test.describe('Chat Smart Learning Assistant', () => {
// Seed content into IndexedDB before page load via Dexie
await page.addInitScript(() => {
// Directly seed IndexedDB using low-level API
const request = indexedDB.open('echotype', 5);
const request = indexedDB.open('echotype:anonymous');
request.onupgradeneeded = (event) => {
const idb = (event.target as IDBOpenDBRequest).result;
if (!idb.objectStoreNames.contains('contents')) {
Expand Down
12 changes: 7 additions & 5 deletions e2e/dashboard-daily-plan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ async function seedDailyPlanFixture(
localStorage.removeItem('echotype_assessment');
}

const request = indexedDB.open('echotype');
const request = indexedDB.open('echotype:anonymous');
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'));
request.onsuccess = () => resolve(request.result);
Expand Down Expand Up @@ -495,6 +495,10 @@ async function getPlanTaskHrefs(page: Page): Promise<string[]> {

async function emitCurrentPracticeText(page: Page, mode: 'speak' | 'read') {
const transcript = await page.evaluate((currentMode) => {
if (currentMode === 'read') {
return document.querySelector('[data-testid="read-reference-scroll"]')?.textContent?.trim() ?? '';
}

const lines = Array.from(document.querySelectorAll('main p'))
.map((element) => element.textContent?.trim() ?? '')
.filter(Boolean)
Expand All @@ -504,9 +508,7 @@ async function emitCurrentPracticeText(page: Page, mode: 'speak' | 'read') {
return lines.find((text) => text.endsWith('?') || text.endsWith('.')) ?? '';
}

return lines
.filter((text) => text !== 'article · Read Aloud Mode')
.join(' ');
return lines.join(' ');
}, mode);

await page.evaluate((text) => {
Expand Down Expand Up @@ -595,7 +597,7 @@ test.describe('Dashboard daily plan', () => {
await page.waitForSelector('main[data-seeded="true"]', { timeout: 15000 });

await expect(page.getByRole('heading', { name: "Today's Plan" })).toBeVisible();
await expect(page.getByText(/Based on your Advanced level/i)).toBeVisible();
await expect(page.getByText(/Based on your C1 level/i)).toBeVisible();
await expect(page.locator('a[href="/write/book/tem8"]')).toBeVisible();
await expect(page.getByText('TEM-8')).toBeVisible();
await expect(page.locator('a[href="/read/read-advanced"]')).toBeVisible();
Expand Down
5 changes: 3 additions & 2 deletions e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ test.describe('Landing Page', () => {
await expect(page.getByText('Start Learning')).toBeVisible();
});

test('displays all 4 feature cards', async ({ page }) => {
test('displays all feature cards', async ({ page }) => {
await page.goto('/');
// Use heading role to avoid matching description paragraphs
await expect(page.getByRole('heading', { name: 'Listen' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Speak & Read' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Speak' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Read' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Write' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'AI Tutor' })).toBeVisible();
});
Expand Down
217 changes: 217 additions & 0 deletions e2e/listen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,223 @@ test.describe('Listen Module', () => {
await expect(page.getByRole('button', { name: '1.5x' })).toBeVisible();
});

test('listen detail keeps playback controls above an independently scrollable transcript', async ({ page }) => {
await navigateToContentDetail(page, 'listen');

const controls = page.getByTestId('read-aloud-inline-controls');
const transcript = page.getByTestId('listen-content-text');
await expect(controls).toBeVisible();
await expect(transcript).toBeVisible();

const [controlsBox, transcriptBox, transcriptStyle] = await Promise.all([
controls.boundingBox(),
transcript.boundingBox(),
transcript.evaluate((element) => {
const style = window.getComputedStyle(element);
return { maxHeight: style.maxHeight, overflowY: style.overflowY };
}),
]);

expect(controlsBox).not.toBeNull();
expect(transcriptBox).not.toBeNull();
expect(controlsBox!.y).toBeLessThan(transcriptBox!.y);
expect(transcriptStyle.maxHeight).not.toBe('none');
expect(['auto', 'scroll']).toContain(transcriptStyle.overflowY);
});

test('listen playback scrolls the transcript to keep the highlighted word visible', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'echotype_tts_settings',
JSON.stringify({ voiceSource: 'browser', voiceURI: 'mock-voice', speed: 1, pitch: 1, volume: 1 }),
);

class MockSpeechSynthesisUtterance {
text: string;
rate = 1;
pitch = 1;
volume = 1;
lang = 'en-US';
voice: SpeechSynthesisVoice | null = null;
onstart: ((event: SpeechSynthesisEvent) => void) | null = null;
onboundary: ((event: SpeechSynthesisEvent) => void) | null = null;
onend: ((event: SpeechSynthesisEvent) => void) | null = null;
onerror: ((event: SpeechSynthesisErrorEvent) => void) | null = null;

constructor(text: string) {
this.text = text;
}
}

const synth = {
speaking: false,
pending: false,
paused: false,
onvoiceschanged: null,
getVoices: () => [
{ voiceURI: 'mock-voice', name: 'Mock Voice', lang: 'en-US', localService: true, default: true },
],
cancel() {
this.speaking = false;
},
pause() {},
resume() {},
speak(utterance: MockSpeechSynthesisUtterance) {
this.speaking = true;
utterance.onstart?.({} as SpeechSynthesisEvent);
const wordOffsets = Array.from(String(utterance.text).matchAll(/\S+/g), (match) => match.index ?? 0).slice(0, 160);
wordOffsets.forEach((charIndex, index) => {
window.setTimeout(() => {
utterance.onboundary?.({ name: 'word', charIndex } as SpeechSynthesisEvent);
}, 20 * (index + 1));
});
},
};

Object.defineProperty(window, 'SpeechSynthesisUtterance', {
configurable: true,
writable: true,
value: MockSpeechSynthesisUtterance,
});
Object.defineProperty(window, 'speechSynthesis', { configurable: true, get: () => synth });
});

await waitForSeedAndReload(page, '/listen');
await page.evaluate(async () => {
await new Promise<void>((resolve, reject) => {
const request = indexedDB.open('echotype:anonymous');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const transaction = request.result.transaction('contents', 'readwrite');
transaction.objectStore('contents').put({
id: 'e2e-listen-autoscroll',
title: 'Long playback fixture',
text: Array.from({ length: 500 }, (_, index) => `word${index}`).join(' '),
type: 'article',
tags: ['e2e'],
source: 'imported',
createdAt: Date.now(),
updatedAt: Date.now(),
});
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
};
});
});
await page.goto('/listen/e2e-listen-autoscroll');

const transcript = page.getByTestId('listen-content-text');
await expect(transcript).toBeVisible();
await page.getByRole('button', { name: 'Play' }).click();

await expect
.poll(() => transcript.evaluate((element) => element.scrollTop), { timeout: 10000 })
.toBeGreaterThan(0);
await page.waitForFunction(() => {
return Array.from(document.querySelectorAll('[data-read-aloud-word]')).some(
(word) => window.getComputedStyle(word).backgroundColor === 'rgb(249, 115, 22)',
);
});
});

test('listen pause preserves highlighting and play resumes the existing utterance', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem(
'echotype_tts_settings',
JSON.stringify({ voiceSource: 'browser', voiceURI: 'mock-voice', speed: 1, pitch: 1, volume: 1 }),
);

class MockSpeechSynthesisUtterance {
text: string;
rate = 1;
pitch = 1;
volume = 1;
lang = 'en-US';
voice: SpeechSynthesisVoice | null = null;
onstart: ((event: SpeechSynthesisEvent) => void) | null = null;
onboundary: ((event: SpeechSynthesisEvent) => void) | null = null;
onend: ((event: SpeechSynthesisEvent) => void) | null = null;
onerror: ((event: SpeechSynthesisErrorEvent) => void) | null = null;

constructor(text: string) {
this.text = text;
}
}

const synth = {
speaking: false,
pending: false,
paused: false,
onvoiceschanged: null,
speakCalls: 0,
resumeCalls: 0,
getVoices: () => [
{ voiceURI: 'mock-voice', name: 'Mock Voice', lang: 'en-US', localService: true, default: true },
],
cancel() {
this.speaking = false;
this.paused = false;
},
pause() {
this.paused = true;
},
resume() {
this.paused = false;
this.resumeCalls += 1;
},
speak(utterance: MockSpeechSynthesisUtterance) {
this.speaking = true;
this.speakCalls += 1;
utterance.onstart?.({} as SpeechSynthesisEvent);
const charIndex = String(utterance.text).search(/\S/);
window.setTimeout(() => utterance.onboundary?.({ name: 'word', charIndex } as SpeechSynthesisEvent), 20);
},
};

Object.defineProperty(window, 'SpeechSynthesisUtterance', {
configurable: true,
writable: true,
value: MockSpeechSynthesisUtterance,
});
Object.defineProperty(window, 'speechSynthesis', { configurable: true, get: () => synth });
(window as any).__listenPauseSynth = synth;
});

await navigateToContentDetail(page, 'listen');
await page.getByRole('button', { name: 'Play' }).click();

const currentWord = page.locator('[data-read-aloud-word]').filter({ hasText: /^.+$/ }).first();
await expect
.poll(() =>
page.evaluate(() =>
Array.from(document.querySelectorAll('[data-read-aloud-word]')).findIndex(
(word) => window.getComputedStyle(word).backgroundColor === 'rgb(249, 115, 22)',
),
),
)
.toBeGreaterThanOrEqual(0);

await page.getByRole('button', { name: 'Pause' }).click();
await expect(currentWord).toBeVisible();
await expect
.poll(() =>
page.evaluate(() =>
Array.from(document.querySelectorAll('[data-read-aloud-word]')).some(
(word) => window.getComputedStyle(word).backgroundColor === 'rgb(249, 115, 22)',
),
),
)
.toBe(true);

await page.getByRole('button', { name: 'Play' }).click();
await expect
.poll(() => page.evaluate(() => (window as any).__listenPauseSynth.resumeCalls))
.toBe(1);
await expect
.poll(() => page.evaluate(() => (window as any).__listenPauseSynth.speakCalls))
.toBe(1);
});

test('listen detail shows content text as clickable words', async ({ page }) => {
await navigateToContentDetail(page, 'listen');

Expand Down
8 changes: 3 additions & 5 deletions e2e/read-word-highlight.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,12 @@ test.describe('Read word highlight', () => {
this.speaking = true;
clearTimers();

const words = String(utterance.text || '')
.split(/\s+/)
.filter(Boolean);
const wordOffsets = Array.from(String(utterance.text || '').matchAll(/\S+/g), (match) => match.index ?? 0).slice(0, 8);

words.slice(0, 8).forEach((_, index) => {
wordOffsets.forEach((charIndex, index) => {
timers.push(
window.setTimeout(() => {
utterance.onboundary?.({ name: 'word', charIndex: index } as SpeechSynthesisEvent);
utterance.onboundary?.({ name: 'word', charIndex } as SpeechSynthesisEvent);
}, 120 * (index + 1)),
);
});
Expand Down
4 changes: 2 additions & 2 deletions e2e/today-review-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ async function seedReviewFixture(page: Page) {
await page.evaluate(async ({ contents, records }) => {
localStorage.removeItem('echotype_daily_plan');

const request = indexedDB.open('echotype');
const request = indexedDB.open('echotype:anonymous');
const db = await new Promise<IDBDatabase>((resolve, reject) => {
request.onerror = () => reject(request.error ?? new Error('Failed to open IndexedDB'));
request.onsuccess = () => resolve(request.result);
Expand Down Expand Up @@ -164,7 +164,7 @@ test.describe('Today review mode', () => {
await page.waitForSelector('main[data-seeded="true"]', { timeout: 15000 });

await expect(page.getByRole('heading', { name: "Today's Review" })).toBeVisible();
await expect(page.getByText('2 items due for review')).toBeVisible();
await expect(page.getByText('2 item(s) due for review')).toBeVisible();
await expect(page.getByRole('button', { name: 'Open Review' })).toBeVisible();
await expect(page.getByRole('heading', { name: "Today's Plan" })).toBeVisible();
await expect(page.getByText("Reviews due today appear above in Today's Review.")).toBeVisible();
Expand Down
4 changes: 2 additions & 2 deletions e2e/write.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ test.describe('Write Module', () => {
test('write list page loads with content', async ({ page }) => {
await waitForSeedAndReload(page, '/write');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Write');
await expect(page.getByText('Practice typing English with real-time feedback')).toBeVisible();
await expect(page.getByRole('main').getByText('Practice typing English with real-time feedback').first()).toBeVisible();

// Switch to Phrase tab to see content items in grid
await page.locator('div.flex.gap-2 button', { hasText: 'Phrase' }).first().click();
Expand Down Expand Up @@ -91,7 +91,7 @@ test.describe('Write Module', () => {
const input = page.locator('input[aria-label="Typing input"]');

// Get the first character of the displayed text
const firstChar = page.locator('.font-mono span').first();
const firstChar = page.locator('.font-mono > span > span').first();
const charText = await firstChar.textContent();

if (charText) {
Expand Down
Loading
Loading