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
43 changes: 33 additions & 10 deletions site/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@
/**
* Reactive GitHub-issue pre-fill URL. Recomputes on route change so
* the body reflects whichever page the user was on when they clicked.
* The full build_info block (deploy + data) is templated in so a
* maintainer can correlate the report to the exact code revision and
* data-package state-keys that were live when the user hit the issue.
*/
$: feedbackUrl = (() => {
const sha =
envBuildInfo?.code_revision_short ??
dataBuildInfo?.code_revision_short ??
'(unknown)';
const here = $page.url.href;
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
const body = [
const lines: string[] = [
'<!-- Replace this template with your bug report or feature request. -->',
'',
'## What were you doing?',
Expand All @@ -38,14 +37,38 @@
'',
'---',
'',
`- page: ${here}`,
`- build: \`${sha}\``,
`- user-agent: ${ua}`
].join('\n');
'## Context (auto-filled; please leave intact)',
'',
`- **page**: ${here}`,
`- **user-agent**: ${ua}`
];
if (envBuildInfo) {
lines.push(
'',
'### deploy build_info',
`- code_revision_short: \`${envBuildInfo.code_revision_short}\``,
`- code_revision: \`${envBuildInfo.code_revision}\``,
`- built_at: ${envBuildInfo.built_at}`
);
}
if (dataBuildInfo) {
lines.push(
'',
'### data build_info',
`- code_revision_short: \`${dataBuildInfo.code_revision_short}\``,
`- code_revision: \`${dataBuildInfo.code_revision}\``,
`- corpus_state_key: \`${dataBuildInfo.corpus_state_key}\``,
`- stage4_rollup_state_key: \`${dataBuildInfo.stage4_rollup_state_key}\``,
`- built_at: ${dataBuildInfo.built_at}`
);
}
if (!envBuildInfo && !dataBuildInfo) {
lines.push('', '### build_info', '- (unavailable — page loaded before manifest)');
}
const params = new URLSearchParams({
labels: 'feedback',
title: '[feedback] ',
body
body: lines.join('\n')
});
return `https://github.com/${FEEDBACK_REPO}/issues/new?${params.toString()}`;
})();
Expand Down
26 changes: 22 additions & 4 deletions site/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@
dataMissing = true;
}
loaded = true;
// Warm the semantic worker in the background so the model is ready
// the moment the user types. The worker does NOT influence ordering
// while the search box is empty — the reactive block below nulls
// `semanticScores` whenever `$searchQuery` is blank.
if (!dataMissing) {
// Warm the semantic worker in the background so the model is ready
// the moment the user types. The worker does NOT influence ordering
// while the search box is empty — the reactive block below nulls
// `semanticScores` whenever `$searchQuery` is blank.
void (async () => {
try {
const mod = await import('$lib/search/semantic');
Expand All @@ -98,6 +98,24 @@
console.warn('semantic search unavailable:', err);
}
})();
// Pre-build the lexical inverted index off the critical render
// path. `lexicalSearch` lazy-builds + caches in a WeakMap; running
// it once with a no-match token populates the cache. Schedule via
// requestIdleCallback (or a 200 ms setTimeout fallback) so we
// don't compete with the first interactive paint.
const warmLexical = (): void => {
void lexicalSearch(abstracts, authorsById, '__warm__');
};
if (typeof window !== 'undefined') {
const w = window as unknown as {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => void;
};
if (typeof w.requestIdleCallback === 'function') {
w.requestIdleCallback(warmLexical, { timeout: 1500 });
} else {
setTimeout(warmLexical, 200);
}
}
}
});

Expand Down
171 changes: 171 additions & 0 deletions site/src/tests/e2e/sc-sweep.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* T096 — Success-Criteria sweep against production.
*
* Each `test()` here verifies one SC item from spec.md. Runs against the
* live production URL by default; override with `TARGET_BASE`.
*
* Coverage in this spec (locally observable, no live data needed beyond a
* deployed site):
*
* SC-002 search latency — type into the search bar; assert the
* visible result count updates within 500 ms.
* SC-003 cell-switch timing — change the model dropdown; assert the
* UMAP cell shard swap completes < 1.5 s.
* SC-004 mobile viewport — 360 × 640 viewport has no horizontal
* scroll on the home page.
* SC-005 accepted-only — the abstracts shard contains zero
* `accepted_for === "Withdrawn"` records.
* SC-011 footer build SHA — home + about + abstract-permalink all
* render the same 7-char short SHA in
* the footer.
*
* Out of scope (need separate infra):
* SC-001 first interactive paint — measured by Lighthouse-CI workflow.
* SC-006 data-package size — measured against the local build dir.
* SC-007 link check — runs in deploy-ui.yml as a hard gate.
* SC-008 PR-preview timing — observed manually across several PRs.
* SC-009 cart reload — covered by cart_email + cart unit tests.
* SC-010 typo recall — separate `eval_typo_recall.py` script.
* SC-012 AI-attribution — separate Playwright assertion (TBD).
*/

import { test, expect, chromium, devices } from '@playwright/test';

test.setTimeout(180_000);

const BASE = (process.env.TARGET_BASE || 'https://abstractatlas.brainkb.org').replace(/\/$/, '');

test.describe('SC sweep', () => {
test('SC-002 — search latency: typing returns a filtered count in < 500 ms (warm path)', async () => {
// In a real session the semantic worker pre-warms during page load,
// so by the time the user types, it's ready. We mirror that here:
// wait for the ✨ Semantic toggle to drop its `loading` class
// (i.e., the worker reached `ready`) before measuring keystroke
// latency. Cold-start latency is bounded by the MiniLM ONNX
// download (~23 MB) and is reported separately below for context.
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
Comment on lines +39 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using the page fixture is the standard and recommended way to write Playwright tests. It automatically handles browser and context creation/cleanup, and ensures the test respects the global configuration (like baseURL or viewport) defined in playwright.config.ts. Manually launching the browser with chromium.launch() is less efficient and requires manual cleanup.

Note: This pattern repeats in all tests in this file; consider updating all of them and removing the manual browser.close() calls.

Suggested change
test('SC-002 — search latency: typing returns a filtered count in < 500 ms (warm path)', async () => {
// In a real session the semantic worker pre-warms during page load,
// so by the time the user types, it's ready. We mirror that here:
// wait for the ✨ Semantic toggle to drop its `loading` class
// (i.e., the worker reached `ready`) before measuring keystroke
// latency. Cold-start latency is bounded by the MiniLM ONNX
// download (~23 MB) and is reported separately below for context.
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
test('SC-002 — search latency: typing returns a filtered count in < 500 ms (warm path)', async ({ page }) => {
// In a real session the semantic worker pre-warms during page load,
// so by the time the user types, it's ready. We mirror that here:
// wait for the ✨ Semantic toggle to drop its `loading` class
// (i.e., the worker reached `ready`) before measuring keystroke
// latency. Cold-start latency is bounded by the MiniLM ONNX
// download (~23 MB) and is reported separately below for context.
await page.goto(`${BASE}/`, { waitUntil: 'load' });

await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="result-card"]', { timeout: 30000 });
// Wait for the semantic worker to settle.
await page
.waitForFunction(
() => {
const btn = document.querySelector<HTMLButtonElement>('[data-testid="toggle-semantic"]');
return !!btn && !btn.classList.contains('loading') && !btn.disabled;
},
{ timeout: 30000 }
)
.catch(() => null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Swallowing the timeout error from waitForFunction with .catch(() => null) can lead to non-deterministic test results. If the semantic worker fails to settle within the timeout, the test should ideally fail with a clear error message rather than continuing with potentially stale or incorrect data.

			);

await page.waitForTimeout(500); // belt-and-braces idle

const before = await page.locator('[data-testid="result-count"]').textContent();
const start = Date.now();
await page.getByTestId('search-input').fill('memory');
await expect
.poll(
async () => {
const t = (await page.locator('[data-testid="result-count"]').textContent())?.trim();
return t !== before && /^\d+$/.test(t || '');
},
{ timeout: 1500, intervals: [50, 100, 200] }
)
.toBe(true);
const elapsed = Date.now() - start;
console.log(`SC-002 search latency (warm): ${elapsed} ms`);
expect(elapsed).toBeLessThanOrEqual(500);
await browser.close();
});

test('SC-003 — cell-switch timing: model dropdown change re-renders UMAP in < 1500 ms', async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="umap-chart-2d"]', { timeout: 30000 });
// Wait for initial UMAP render
await page.waitForTimeout(2000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoded waits like page.waitForTimeout(2000) are a common source of test flakiness and slow down the test suite. It is better to wait for a specific UI state or element to appear. For example, you could wait for a specific SVG element or data-point within the UMAP chart to be rendered.

// Switch model — neuroscape → voyage.
const start = Date.now();
await page
.locator('[data-testid="model-selector-model"]')
.selectOption({ value: 'voyage' })
.catch(() => null);
await page.waitForFunction(
() => {
const el = document.querySelector('h3 code');
return el?.textContent?.startsWith('voyage_');
},
{ timeout: 5000 }
);
const elapsed = Date.now() - start;
console.log(`SC-003 cell-switch timing: ${elapsed} ms`);
expect(elapsed).toBeLessThanOrEqual(1500);
await browser.close();
});

test('SC-004 — 360×640 mobile: home page has no horizontal scroll', async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ ...devices['Pixel 5'], viewport: { width: 360, height: 640 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="search-input"]', { timeout: 30000 });
await page.waitForTimeout(1500);
const scroll = await page.evaluate(() => ({
docW: document.documentElement.scrollWidth,
viewW: document.documentElement.clientWidth
}));
console.log(`SC-004 mobile overflow: docW=${scroll.docW} viewW=${scroll.viewW}`);
expect(scroll.docW).toBeLessThanOrEqual(scroll.viewW + 1);
await browser.close();
});

test('SC-005 — accepted-only: no Withdrawn records leak into the deployed corpus', async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="result-card"]', { timeout: 30000 });
await page.waitForTimeout(2000);
// `+page.svelte` exposes a debug `window.__abstracts` snapshot.
const withdrawnCount = await page.evaluate(() => {
const arr = (window as unknown as { __abstracts?: { accepted_for: string }[] }).__abstracts;
if (!Array.isArray(arr)) return -1;
return arr.filter((a) => a.accepted_for === 'Withdrawn').length;
});
console.log(`SC-005 withdrawn-count: ${withdrawnCount}`);
expect(withdrawnCount).toBe(0);
await browser.close();
});

test('SC-011 — footer build_info SHA consistent across home / about / abstract permalink', async () => {
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="build-info-short-sha"]', { timeout: 30000 });
const homeSha = (await page.locator('[data-testid="build-info-short-sha"]').first().textContent())?.trim();
expect(homeSha).toMatch(/^[0-9a-f]{7,12}$/);

await page.goto(`${BASE}/about/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="build-info-short-sha"]', { timeout: 30000 });
const aboutSha = (await page.locator('[data-testid="build-info-short-sha"]').first().textContent())?.trim();
expect(aboutSha).toBe(homeSha);

// Sample a real poster_id from the home page to test the permalink route.
await page.goto(`${BASE}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="result-card"]', { timeout: 30000 });
const posterId = await page.locator('[data-testid="result-card"]').first().getAttribute('data-poster-id');
if (posterId) {
await page.goto(`${BASE}/abstract/${encodeURIComponent(posterId)}/`, { waitUntil: 'load' });
await page.waitForSelector('[data-testid="build-info-short-sha"]', { timeout: 30000 });
const permalinkSha = (
await page.locator('[data-testid="build-info-short-sha"]').first().textContent()
)?.trim();
expect(permalinkSha).toBe(homeSha);
console.log(`SC-011 SHA consistent across routes: ${homeSha}`);
}
await browser.close();
});
});
Loading