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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ checks in parallel on Node.js 24. `test:coverage` measures every production Type
only declaration files and does not claim execution coverage for Svelte components. After a
production build, `check:performance` checks the existing Vercel output without rebuilding;
`test:performance` performs both steps. The performance checks cover immutable JS/CSS and the
deterministic 280-problem homepage HTML fixture budgets. Live read-only Supabase smoke tests run
deterministic full 280-problem homepage HTML fixture budgets. Live read-only Supabase smoke tests run
when CI has the required repository variable and secret; otherwise those optional jobs exit cleanly.

## Contributing
Expand Down
46 changes: 13 additions & 33 deletions e2e/homepage-pagination.spec.ts → e2e/homepage-problems.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,22 @@ test.beforeEach(async () => {
await setScenario('large');
});

test('homepage batches complete derived rows accessibly without a hydration refetch', async ({
test('homepage renders every derived row without pagination or a hydration refetch', async ({
page
}) => {
const response = await page.goto('/');
const html = (await response?.text()) ?? '';
const serverBody = html.match(/<tbody id="problem-table-body">([\s\S]*?)<\/tbody>/)?.[1] ?? '';
expect(new TextEncoder().encode(html).byteLength).toBeLessThanOrEqual(400_000);
expect((serverBody.match(/<tr/g) ?? []).length).toBe(50);
expect(new TextEncoder().encode(html).byteLength).toBeLessThanOrEqual(1_350_000);
expect((serverBody.match(/<tr/g) ?? []).length).toBe(280);
expect(html).not.toContain('date_added');

const rows = page.locator('#problem-table-body > tr');
await expect(rows).toHaveCount(50);
await expect(page.getByText('50 of 280 problems shown')).toBeVisible();
const showMore = page.getByRole('button', { name: 'Show 50 more problems' });
await expect(showMore).toHaveAttribute('type', 'button');
await expect(showMore).toHaveAttribute('aria-controls', 'problem-table-body');
await expect(rows).toHaveCount(280);
await expect(page.getByRole('button', { name: /more problems|All problems shown/ })).toHaveCount(
0
);
await page.waitForLoadState('networkidle');
expect(await page.locator('*').count()).toBeLessThanOrEqual(2_500);
const readMetadata = await problemReadMetadata();
expect(readMetadata.problemsReadCount).toBe(1);
expect(readMetadata.lastProblemsSelect?.split(',').map((column) => column.trim())).toEqual([
Expand All @@ -52,34 +50,16 @@ test('homepage batches complete derived rows accessibly without a hydration refe
'type'
]);

await showMore.click();
await expect(rows).toHaveCount(100);
await expect(page.getByText('100 of 280 problems shown')).toBeVisible();

await page.getByLabel('Filter by author').selectOption('beyond-author');
await page.getByLabel('Filter by author').selectOption('full-list-author');
await expect(rows).toHaveCount(1);
await expect(page.getByRole('link', { name: 'Beyond First Batch' })).toBeVisible();
await expect(page.getByRole('button', { name: /more problems|All problems shown/ })).toHaveCount(
0
);
await expect(page.getByRole('link', { name: 'Full List Boundary Problem' })).toBeVisible();

await page.getByLabel('Filter by author').selectOption('all');
await expect(rows).toHaveCount(50);
await page.getByRole('button', { name: /Difficulty, not sorted/i }).click();
await expect(rows.first().getByRole('link', { name: 'Beyond First Batch' })).toBeVisible();

for (const count of [100, 150, 200, 250]) {
await page.getByRole('button', { name: 'Show 50 more problems' }).click();
await expect(rows).toHaveCount(count);
}
const finalBatch = page.getByRole('button', { name: 'Show 30 more problems' });
await finalBatch.click();
await expect(rows).toHaveCount(280);
const complete = page.getByRole('button', { name: 'All problems shown' });
await expect(complete).toBeVisible();
await expect(complete).toBeDisabled();
await expect(complete).toBeFocused();
await expect(page.getByRole('status')).toHaveText('280 of 280 problems shown');
await page.getByRole('button', { name: /Difficulty, not sorted/i }).click();
await expect(
rows.first().getByRole('link', { name: 'Full List Boundary Problem' })
).toBeVisible();
expect((await problemReadMetadata()).problemsReadCount).toBe(1);
});

Expand Down
63 changes: 34 additions & 29 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,33 +157,36 @@ test.describe('header session and appearance controls', () => {
await expect(error).toHaveCount(0);
});

test('desktop appearance popover uses radios and Escape restores focus', async ({
page,
viewport
}) => {
test.skip(!!viewport && viewport.width < 1024, 'desktop appearance popover only');
test('desktop theme icon cycles System, Light, Dark, then System', async ({ page, viewport }) => {
test.skip(!!viewport && viewport.width < 1024, 'desktop theme control only');
await page.goto('/');
const button = page.getByRole('button', { name: 'Appearance' });
const target = await button.boundingBox();
const system = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
const target = await system.boundingBox();
expect(target?.height).toBeGreaterThanOrEqual(44);
expect(target?.width).toBeGreaterThanOrEqual(44);
await expect(button).toHaveText('');
await button.click();
await expect(page.getByRole('radio', { name: 'System' })).toBeChecked();
await page.keyboard.press('Escape');
await expect(page.locator('#appearance-popover')).toHaveCount(0);
await expect(button).toBeFocused();
await expect(system).toHaveText('');
await system.click();
await page.getByRole('button', { name: 'Theme: Light. Switch to Dark' }).click();
await page.getByRole('button', { name: 'Theme: Dark. Switch to System' }).click();
await expect(
page.getByRole('button', { name: 'Theme: System. Switch to Light' })
).toBeVisible();
await expect(page.getByRole('radio')).toHaveCount(0);
});

test('mobile appearance choices meet the 44px target minimum', async ({ page, viewport }) => {
test.skip(!!viewport && viewport.width >= 1024, 'mobile appearance controls only');
test('mobile theme icon remains secondary and meets the 44px target minimum', async ({
page,
viewport
}) => {
test.skip(!!viewport && viewport.width >= 1024, 'mobile theme control only');
await page.goto('/');
await page.getByRole('button', { name: 'Open menu' }).click();
const system = page.getByRole('radio', { name: 'System' });
const target = system.locator('..');
const box = await target.boundingBox();
const theme = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
const box = await theme.boundingBox();
expect(box?.height).toBeGreaterThanOrEqual(44);
expect(box?.width).toBeGreaterThanOrEqual(44);
await expect(theme).toHaveText('');
await expect(page.getByRole('radio')).toHaveCount(0);
});
});

Expand Down Expand Up @@ -272,27 +275,27 @@ test.describe('signed-in settings layout and appearance', () => {
await expect(page.locator('h1.sr-only')).toHaveText('Settings');
await expect(page.getByRole('heading', { name: 'Appearance' })).toHaveCount(0);

const appearanceButton = page.getByRole('button', { name: 'Appearance' });
if (await appearanceButton.isVisible()) await appearanceButton.click();
else {
let themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
if (!(await themeButton.isVisible())) {
await page.getByRole('button', { name: 'Open menu' }).click();
themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
}
const settingsLink = page.getByRole('link', { name: 'Settings' });
const settingsTarget = await settingsLink.boundingBox();
expect(settingsTarget?.height).toBeGreaterThanOrEqual(44);
expect(settingsTarget?.width).toBeGreaterThanOrEqual(44);
await expect(settingsLink).toHaveText('');
await page.getByRole('radio', { name: 'Dark' }).check();
await themeButton.click();
await page.getByRole('button', { name: 'Theme: Light. Switch to Dark' }).click();
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await expect.poll(() => page.evaluate(() => localStorage.getItem('gitgud-theme'))).toBe('dark');

await page.reload();
if (viewport && viewport.width < 1024) {
await page.getByRole('button', { name: 'Open menu' }).click();
} else {
await page.getByRole('button', { name: 'Appearance' }).click();
}
await expect(page.getByRole('radio', { name: 'Dark' })).toBeChecked();
await expect(page.getByRole('button', { name: 'Theme: Dark. Switch to System' })).toBeVisible();
await expect(page.getByRole('radio', { name: /^(System|Light|Dark)$/ })).toHaveCount(0);

const lastCard = page.locator('main section').last();
const footer = page.locator('footer');
Expand Down Expand Up @@ -488,10 +491,12 @@ test.describe('theme startup', () => {
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');

const appearanceButton = page.getByRole('button', { name: 'Appearance' });
if (await appearanceButton.isVisible()) await appearanceButton.click();
else await page.getByRole('button', { name: 'Open menu' }).click();
await page.getByRole('radio', { name: 'Light' }).check();
let themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
if (!(await themeButton.isVisible())) {
await page.getByRole('button', { name: 'Open menu' }).click();
themeButton = page.getByRole('button', { name: 'Theme: System. Switch to Light' });
}
await themeButton.click();
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});
Expand Down
6 changes: 3 additions & 3 deletions e2e/support/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,14 @@ export const LARGE_PROBLEMS: ProblemRow[] = Array.from({ length: 280 }, (_, inde
const isLast = number === 280;
return {
id: `large-${String(number).padStart(3, '0')}`,
name: isLast ? 'Beyond First Batch' : `Large Fixture Problem ${number}`,
name: isLast ? 'Full List Boundary Problem' : `Large Fixture Problem ${number}`,
difficulty: isLast ? 700 : 1000 + number,
url:
number % 2 === 0
? `https://open.kattis.com/problems/large-${number}`
: `https://codeforces.com/contest/2000/problem/${number}`,
added_by: isLast ? 'beyond-author' : `author-${number % 10}`,
added_by_url: `https://example.test/users/${isLast ? 'beyond-author' : `author-${number % 10}`}`,
added_by: isLast ? 'full-list-author' : `author-${number % 10}`,
added_by_url: `https://example.test/users/${isLast ? 'full-list-author' : `author-${number % 10}`}`,
likes: 281 - number,
dislikes: 0,
type: number % 3 === 0 ? 'graph' : 'math'
Expand Down
14 changes: 6 additions & 8 deletions scripts/performance-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const STATIC_BUDGETS = Object.freeze({
});
/** @type {Readonly<Required<FixtureBudgets>>} */
export const HOMEPAGE_FIXTURE_BUDGETS = Object.freeze({
rows: 50,
rows: 280,
totalProblems: 280,
htmlBytes: 24_000,
gzipBytes: 2_000
htmlBytes: 125_000,
gzipBytes: 6_000
});

/**
Expand Down Expand Up @@ -87,9 +87,7 @@ export function renderHomepageTableFixture(rowCount = HOMEPAGE_FIXTURE_BUDGETS.r
const name = escapeHtml(`Fixture Problem ${id} <quality>`);
return `<tr data-problem-id="fixture-${id}"><td><button aria-label="Mark as solved">✓</button></td><td>${source}</td><td><a href="https://example.test/problems/${id}">${name}</a></td><td>${800 + (index % 28) * 100}</td><td>dynamic programming</td><td><a href="https://example.test/users/author-${index % 20}">@author-${index % 20}</a></td><td><button aria-label="Like, ${index % 17} likes">${index % 17}</button><button aria-label="Dislike, ${index % 5} dislikes">${index % 5}</button></td></tr>`;
});
const remaining = Math.max(HOMEPAGE_FIXTURE_BUDGETS.totalProblems - rowCount, 0);
const nextCount = Math.min(rowCount, remaining);
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Problems fixture</title></head><body><main><h1>Problems</h1><table><thead><tr><th>Solved</th><th>Source</th><th>Problem</th><th>Difficulty</th><th>Topic</th><th>Recommender</th><th>Feedback</th></tr></thead><tbody id="problem-table-body">${rows.join('')}</tbody></table><button type="button" aria-controls="problem-table-body" aria-label="Show ${nextCount} more problems">Show ${nextCount} more</button><p role="status" aria-live="polite">${rowCount} of ${HOMEPAGE_FIXTURE_BUDGETS.totalProblems} problems shown</p></main></body></html>`;
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Problems fixture</title></head><body><main><h1>Problems</h1><table><thead><tr><th>Solved</th><th>Source</th><th>Problem</th><th>Difficulty</th><th>Topic</th><th>Recommender</th><th>Feedback</th></tr></thead><tbody id="problem-table-body">${rows.join('')}</tbody></table></main></body></html>`;
}

export function measureHomepageFixture(rowCount = HOMEPAGE_FIXTURE_BUDGETS.rows) {
Expand Down Expand Up @@ -122,8 +120,8 @@ export function budgetFailures(
budgets.largestJavaScriptGzipBytes
],
['largest immutable CSS gzip', summary.largestCss?.gzipBytes ?? 0, budgets.largestCssGzipBytes],
['initial homepage fixture HTML', fixture.htmlBytes, fixtureBudgets.htmlBytes],
['initial homepage fixture gzip', fixture.gzipBytes, fixtureBudgets.gzipBytes]
['full homepage fixture HTML', fixture.htmlBytes, fixtureBudgets.htmlBytes],
['full homepage fixture gzip', fixture.gzipBytes, fixtureBudgets.gzipBytes]
];
return checks
.filter(([, actual, budget]) => actual > budget)
Expand Down
Loading
Loading