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: 6 additions & 0 deletions docs/performance/smooth-navigation-baseline/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ requests, so they prove rendering and browser timing only.

## Reproduce

The harness `scripts/measure-navigation-performance.mjs` was a single-use
baseline tool and was removed once this evidence was captured. Recover it with
`git show 9127589:scripts/measure-navigation-performance.mjs` if the matrix ever
needs re-running. The commands below are the ones that produced `run-1.json`
and `run-2.json`.

```text
export PATH=/home/codex/.nvm/versions/node/v22.23.2/bin:$PATH
node --version # v22.23.2
Expand Down
27 changes: 23 additions & 4 deletions e2e/animation-polish.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { expect, test, type Page } from '@playwright/test';
import { stubSupabaseSession } from './helpers/auth';
import { stubAIResponses } from './fixtures/ai-responses';

const featureConfig = { provider: 'mock', model: 'local', temperature: 0.7 };
const featureConfig = { provider: 'openrouter', model: 'z-ai/glm-5.3-flash', temperature: 0.7 };
const STATE_TIMEOUT_MS = 15_000;

async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') {
await page.emulateMedia({ reducedMotion });
await stubSupabaseSession(page);
await stubAIResponses(page);
await page.addInitScript((config) => {
window.localStorage.setItem('debate-trainer-settings', JSON.stringify({
state: {
Expand Down Expand Up @@ -47,10 +49,27 @@ async function openGenerateOptions(page: Page) {
async function generateMockCase(page: Page) {
await page.getByLabel('Motion / Topic').fill('This House would make public transport free.');
await page.getByRole('button', { name: 'Generate Debate Case' }).click();
await page.mouse.move(0, 0);
await expect(page.getByText('Reading the motion...')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS });
}

async function openFeatureRoute(page: Page, route: 'brainstorm' | 'factoids' | 'ask') {
const desktopLink = page.locator(`[data-tour="nav-${route}"]`).first();

if ((page.viewportSize()?.width ?? 1280) < 1024) {
await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('navigation', { name: 'Mobile navigation' })
.locator(`a[href="/app/${route}"]`)
.click();
} else {
if (!await desktopLink.isVisible()) await page.locator('summary[data-tour="nav-more"]').click();
await desktopLink.click();
}

await expect(page).toHaveURL(new RegExp(`/app/${route}$`));
}

test('keeps route and feature-state motion short and stable', async ({ page }, testInfo) => {
await prepareMotionFixture(page, 'no-preference');
const options = await openGenerateOptions(page);
Expand Down Expand Up @@ -139,21 +158,21 @@ test('audits applicable empty, loading, result, and option states across feature
await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true });

await page.goto('/app/brainstorm', { waitUntil: 'domcontentloaded' });
await openFeatureRoute(page, 'brainstorm');
await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await page.getByLabel('Motion / Topic').fill('This House would make public transport free.');
await page.getByRole('button', { name: 'Start Brainstorming' }).click();
await expect(page.getByText('Exploring angles...')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS });

await page.goto('/app/factoids', { waitUntil: 'domcontentloaded' });
await openFeatureRoute(page, 'factoids');
await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.');
await page.getByRole('button', { name: 'Find Factoids' }).click();
await expect(page.getByText('Searching databases...')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS });

await page.goto('/app/ask', { waitUntil: 'domcontentloaded' });
await openFeatureRoute(page, 'ask');
await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS });
await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?');
await page.getByRole('button', { name: 'Send message' }).click();
Expand Down
184 changes: 184 additions & 0 deletions e2e/fixtures/ai-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Canned edge-function responses for specs that exercise UI states rather than
* AI quality. Intercepting the network keeps the app's real request path — and
* its schema validation — under test, unlike the deleted in-app mock provider
* which bypassed both.
*/
import type { Page } from '@playwright/test';

const CORS_HEADERS = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
'content-type': 'application/json',
};

// The client rejects a generate response whose contention count does not match
// the requested length (see expectedContentionCount in src/lib/ai/client.ts).
const CONTENTION_COUNT: Record<string, number> = { brief: 2, standard: 3, comprehensive: 4 };

const CONTENTIONS = [
{
id: '1',
claim: 'Access improves for the people who need it most',
reasoning: 'Fares are a regressive barrier concentrated on low-income riders.',
evidence: 'Comparable jurisdictions report ridership gains after fare removal.',
needsSource: false,
impact: 'More people reach work, school, and healthcare.',
},
{
id: '2',
claim: 'Congestion and emissions fall as mode share shifts',
reasoning: 'Removing the marginal cost of a trip shifts journeys off the road.',
evidence: 'Mode-share studies show measurable shifts at zero fare.',
needsSource: false,
impact: 'Shorter journeys and lower urban emissions.',
},
{
id: '3',
claim: 'Fare collection overhead outweighs much of the revenue it protects',
reasoning: 'Gates, inspectors, and ticketing systems consume a large share of receipts.',
evidence: 'Operators report collection costs well above headline estimates.',
needsSource: false,
impact: 'The net funding gap is far smaller than opponents claim.',
},
{
id: '4',
claim: 'Civic participation rises when mobility is not rationed by price',
reasoning: 'Unpriced trips are taken for non-essential civic and social reasons too.',
evidence: 'Ridership growth after fare removal skews to off-peak trips.',
needsSource: false,
impact: 'Denser civic life, not just cheaper commutes.',
},
];

const generate = (motion: string, length: string) => ({
caseStructure: {
thesis: `We support the motion that ${motion}`,
framework: 'Net societal benefit, weighed on access and cost.',
keyDefinitions: ['Public transport: publicly funded scheduled transit.'],
burdenOfProof: 'We must show the policy produces a net gain in practice.',
},
contentions: CONTENTIONS.slice(0, CONTENTION_COUNT[length] ?? 2),
predictedOpponentCase: {
likelyArguments: ['The lost farebox revenue has to come from somewhere.'],
strategicWeaknesses: ['Assumes fare revenue is the only funding model.'],
keyClashes: ['Who actually bears the cost.'],
},
counters: [
{
targetArgument: 'Funding gap',
counterpoint: 'Fare collection itself carries substantial overhead.',
rebuttal: 'Net cost is far below headline farebox revenue.',
},
],
metadata: { side: 'pro', format: 'bp', generatedAt: new Date().toISOString() },
});

const attack = {
overallAssessment: {
score: 62,
summary: 'The case is coherent but leaves its central warrant underdefended.',
strengthAreas: ['Clear thesis', 'Explicit framework'],
},
weaknesses: [
{
id: 'w1',
severity: 'major',
type: 'evidential',
description: 'The congestion claim has no comparative evidence.',
whyWeak: 'Correlation between fare policy and congestion is asserted, not shown.',
howOpponentAttacks: 'Name a city where ridership rose and congestion did not fall.',
improvedVersion: 'Cite a before/after study with a control corridor.',
},
],
fallaciesDetected: [
{ name: 'Hasty generalization', location: 'Second contention', explanation: 'One city generalized to all.' },
],
crossExQuestions: [
{ question: 'Which funding line replaces the farebox?', purpose: 'Force a concrete mechanism.', expectedTrap: 'A vague appeal to general taxation.' },
],
weighingAnalysis: {
currentWeighing: 'Access is asserted to outweigh cost without comparison.',
missingComparisons: ['Cost per additional rider versus targeted subsidy.'],
improvementSuggestions: ['Weigh against the strongest alternative, not the status quo.'],
},
};

const idea = {
id: 'i1',
title: 'Access as a precondition for participation',
description: 'Mobility gates work, education, and civic life.',
side: 'pro',
strength: 'strong',
tradeoffs: ['Requires a durable funding source.'],
relatedTo: [],
};

const brainstorm = {
needsMoreInfo: false,
topFive: [{ rank: 1, idea, whyTop: 'It reaches the widest impact with the clearest warrant.' }],
nextSteps: ['Find a before/after ridership study.'],
warningsAndTradeoffs: ['Do not overclaim the congestion effect.'],
};

const factoids = {
factoids: [
{
id: 'f1',
shortClaim: 'Fare removal raised ridership in Tallinn',
keyFigure: '+14% ridership',
context: 'Measured over the first year of the free-fare scheme.',
limitations: 'A single city with unusual baseline car ownership.',
citation: { title: 'Free public transport evaluation', publisher: 'Municipal transport authority', verified: 'verified' },
howToUse: 'Use as an existence proof, not as a universal rate.',
relevanceScore: 80,
},
],
searchSuggestions: ['fare-free transit ridership study'],
warnings: [],
verificationStatus: 'complete',
};

const ask = (message: string) => ({
response: `Great question about ${message.slice(0, 60)} — weigh access against cost by naming who bears each.`,
followUpQuestions: ['What is the strongest alternative policy?'],
uncertainties: ['The congestion effect size varies widely by city.'],
});

type AiRequest = { feature?: string; input?: Record<string, unknown> };

// The deleted mock provider delayed 1-2s per feature, which is what made the
// loading states observable. Specs assert on those states, so keep the latency.
const RESPONSE_DELAY_MS = 1_200;

/**
* Serves canned output for every `ai` edge-function call. Specs that need a
* specific payload should add their own narrower `page.route` first.
*/
export async function stubAIResponses(page: Page) {
await page.route('**/functions/v1/ai**', async (route) => {
if (route.request().method() === 'OPTIONS') {
await route.fulfill({ status: 200, headers: CORS_HEADERS, body: 'ok' });
return;
}
const body = (route.request().postDataJSON() ?? {}) as AiRequest;
const motion = typeof body.input?.motion === 'string' ? body.input.motion : 'the motion';
const message = typeof body.input?.message === 'string' ? body.input.message : 'this';
const length = typeof body.input?.length === 'string' ? body.input.length : 'brief';
const output = {
generate: () => generate(motion, length),
attack: () => attack,
brainstorm: () => brainstorm,
factoids: () => factoids,
ask: () => ask(message),
}[body.feature ?? '']?.();

if (!output) {
await route.fulfill({ status: 400, headers: CORS_HEADERS, body: JSON.stringify({ error: `No fixture for feature ${body.feature}` }) });
return;
}
await new Promise((resolve) => setTimeout(resolve, RESPONSE_DELAY_MS));
await route.fulfill({ status: 200, headers: CORS_HEADERS, body: JSON.stringify({ output, discoveredSources: [] }) });
});
}
29 changes: 10 additions & 19 deletions e2e/home-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,27 +60,19 @@ test('onboarding follows visible home targets when secondary navigation is close

test('onboarding keeps its original start schedule after a delayed lazy chunk', async ({ page }) => {
await page.addInitScript(() => {
const originalSetItem = Storage.prototype.setItem;
Storage.prototype.setItem = function setItem(key, value) {
if (key === 'debate-trainer-onboarding') {
try {
if (JSON.parse(value).state?.isTourRunning === true) {
originalSetItem.call(this, '__onboarding-tour-started-at', String(Date.now()));
}
} catch {
// Ignore malformed test-only storage writes.
}
}
originalSetItem.call(this, key, value);
};
window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
state: { hasCompletedOnboarding: false, isTourRunning: false },
version: 0,
}));
});
await page.route('**/src/components/features/onboarding/OnboardingTour.tsx*', async (route) => {
const response = await route.fetch();
const body = `${await response.text()}\nwindow.localStorage.setItem('__onboarding-module-evaluated-at', String(Date.now()));`;
const source = await response.text();
const body = source.replace(
/const\s+timer\s*=\s*setTimeout\(\(\)\s*=>\s*\{/,
'window.localStorage.setItem("__onboarding-auto-start-delay", String(autoStartDelay));\n const timer = setTimeout(() => {',
);
if (body === source) throw new Error('Unable to instrument the onboarding auto-start delay');
await new Promise((resolve) => setTimeout(resolve, 750));
await route.fulfill({ response, body });
});
Expand All @@ -92,11 +84,10 @@ test('onboarding keeps its original start schedule after a delayed lazy chunk',

await page.goto('/app', { waitUntil: 'domcontentloaded' });

await expect.poll(async () => Number(await page.evaluate(() => window.localStorage.getItem('__onboarding-module-evaluated-at') ?? 0)), { timeout: 15_000 }).toBeGreaterThan(0);
await expect.poll(async () => Number(await page.evaluate(() => window.localStorage.getItem('__onboarding-tour-started-at') ?? 0)), { timeout: 15_000 }).toBeGreaterThan(0);
const moduleEvaluatedAt = await page.evaluate(() => Number(window.localStorage.getItem('__onboarding-module-evaluated-at')));
const startedAt = await page.evaluate(() => Number(window.localStorage.getItem('__onboarding-tour-started-at')));
expect(startedAt - moduleEvaluatedAt).toBeLessThan(450);
await expect.poll(
() => page.evaluate(() => Number(window.localStorage.getItem('__onboarding-auto-start-delay') ?? -1)),
{ timeout: 15_000 },
).toBe(0);
await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 });
});

Expand Down
2 changes: 1 addition & 1 deletion e2e/hungarian-parity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ test.describe('Hungarian parity', () => {
await page.getByRole('button', { name: 'Forrás Hozzáadása' }).click();
await expect(page.getByText('Importálás URL-ből')).toBeVisible();
await page.getByRole('button', { name: 'Mentés a Könyvtárba' }).click();
await expect(page.locator('[toast-close]')).toHaveAttribute('aria-label', 'Bezárás');
await expect(page.locator('[data-sonner-toast] [data-close-button]')).toHaveAttribute('aria-label', 'Bezárás');

await page.goto('/app/flowchart', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: 'Betöltés' }).click();
Expand Down
26 changes: 20 additions & 6 deletions e2e/navigation-performance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ async function openAppNav(page: Page, label: string) {
}

test('cold routes expose real content on both browser profiles', async ({ page }) => {
test.setTimeout(60_000);
await setLocale(page, 'en');
for (const [path, selector] of publicRoutes) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
Expand All @@ -79,6 +80,17 @@ test('cold routes expose real content on both browser profiles', async ({ page }
await appPage.close();
});

test('workspaces stay unavailable without a collaboration endpoint', async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await waitForContent(page, { selector: 'main h1', text: 'DebateLab' });

await expect(page.locator('a[href="/app/workspaces"]')).toHaveCount(0);
await page.goto('/app/workspaces', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});

test('a delayed public route keeps the main landmark', async ({ page }) => {
await setLocale(page, 'en');
let releaseTerms: (() => void) | undefined;
Expand Down Expand Up @@ -305,9 +317,13 @@ test('hover intent is limited to fine pointers and click stays in-document', asy
expect(requestCount()).toBe(0);

await link.hover();
await page.waitForTimeout(INTENT_OBSERVATION_MS);
const supportsHover = await page.evaluate(() => window.matchMedia('(hover: hover) and (pointer: fine)').matches);
expect(requestCount()).toBe(supportsHover ? 1 : 0);
if (supportsHover) {
await expect.poll(requestCount).toBe(1);
} else {
await page.waitForTimeout(INTENT_OBSERVATION_MS);
expect(requestCount()).toBe(0);
}

const navigationEntriesBeforeClick = await page.evaluate(() => performance.getEntriesByType('navigation').length);
await startGenericLoaderObservation(page);
Expand All @@ -327,8 +343,7 @@ test('keyboard focus fetches one destination chunk before click', async ({ page

await link.focus();
await expect(link).toBeFocused();
await page.waitForTimeout(INTENT_OBSERVATION_MS);
expect(requestCount()).toBe(1);
await expect.poll(requestCount).toBe(1);

await startGenericLoaderObservation(page);
await link.click();
Expand All @@ -345,8 +360,7 @@ test('touch intent fetches one destination chunk before click', async ({ page })
expect(requestCount()).toBe(0);

await link.dispatchEvent('pointerdown', { pointerType: 'touch' });
await page.waitForTimeout(INTENT_OBSERVATION_MS);
expect(requestCount()).toBe(1);
await expect.poll(requestCount).toBe(1);

await startGenericLoaderObservation(page);
await link.click();
Expand Down
Loading
Loading