Skip to content
Open
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
100 changes: 100 additions & 0 deletions e2e/home-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { test, expect } from './fixtures';
import { seedItemLabel, seedSavedArticle, type TestUser } from './seed';

const BODY_WORD = 'quokkatelemetry';

async function seedHomeLibrary(testUser: TestUser) {
await seedSavedArticle(testUser, {
url: 'https://example.com/gardening',
title: 'Gardening Basics',
domain: 'example.com',
content: '<p>Tomatoes want sun and patience.</p>',
});
await seedSavedArticle(testUser, {
url: 'https://example.com/ownership',
title: 'Ownership Explained',
domain: 'example.com',
content: `<p>The body-only marker is ${BODY_WORD}.</p>`,
});
const archivedRkey = await seedSavedArticle(testUser, {
url: 'https://example.com/sourdough',
title: 'Sourdough Notes',
domain: 'example.com',
content: '<p>Feed the starter twice a day.</p>',
});
await seedItemLabel(testUser, {
itemKey: `at://${testUser.did}/app.skyreader.feed.saved/${archivedRkey}`,
itemType: 'saved',
label: 'archived',
});
}

test.describe('Home search', () => {
test('replaces lanes with title and body results, then restores them on clear', async ({
authedPage,
testUser,
}) => {
await seedHomeLibrary(testUser);
await authedPage.goto('/home');
await expect(authedPage.getByLabel('Recently saved', { exact: true })).toBeVisible({
timeout: 15_000,
});

await authedPage.getByRole('button', { name: 'Search saved items' }).click();
const input = authedPage.getByTestId('saved-search-input');
// Scope every result assertion to the results region. Home shows the same
// save in several lanes, so an unscoped title lookup matches more than once
// during the debounce beat before search replaces them — and a strict-mode
// violation fails the expect on the spot instead of being retried away.
const results = authedPage.getByLabel('Saved search results');

await input.fill('gardening');
await expect(results.getByText('Gardening Basics')).toBeVisible();
await expect(authedPage.getByLabel('Recently saved', { exact: true })).not.toBeVisible();

await input.fill(BODY_WORD);
await expect(results.getByText('Ownership Explained')).toBeVisible({ timeout: 10_000 });
await expect(results.locator('mark', { hasText: BODY_WORD })).toBeVisible();

await input.fill('');
await expect(authedPage.getByLabel('Recently saved', { exact: true })).toBeVisible();
});

test('hands an archived-only query to the Saved archive', async ({ authedPage, testUser }) => {
await seedHomeLibrary(testUser);
await authedPage.goto('/home');
// Wait for the loaded page before pressing a global shortcut: the handler is
// registered on mount, and a `/` that lands before that is simply dropped.
await expect(authedPage.getByLabel('Recently saved', { exact: true })).toBeVisible({
timeout: 15_000,
});

await authedPage.locator('body').press('/');
const input = authedPage.getByTestId('saved-search-input');
await expect(input).toBeFocused();
await input.fill('sourdough');

const hint = authedPage.getByRole('button', { name: '1 match in your Saved archive' });
await expect(hint).toBeVisible({ timeout: 10_000 });
await hint.click();

await expect(authedPage).toHaveURL(/\/saved$/);
await expect(authedPage.getByText('Sourdough Notes')).toBeVisible();
await expect(authedPage.getByTestId('saved-search-input')).toHaveValue('sourdough');
});

test.describe('on a mobile viewport', () => {
test.use({ viewport: { width: 390, height: 844 } });

test('the bottom bar opens search', async ({ authedPage, testUser }) => {
await seedHomeLibrary(testUser);
await authedPage.goto('/home');
await expect(authedPage.getByLabel('Recently saved', { exact: true })).toBeVisible({
timeout: 15_000,
});

await authedPage.getByRole('button', { name: 'Search saved items' }).click();
await expect(authedPage.getByTestId('saved-search-input')).toBeFocused();
});
});
});
21 changes: 12 additions & 9 deletions e2e/saved-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ test.describe('Saved search', () => {
await expect(authedPage.getByText('An Afternoon at the Café')).not.toBeVisible();

// Client-side navigation, deliberately: a full reload would rebuild every
// store and hide the residual-state bug this covers.
await authedPage.getByRole('link', { name: 'Home' }).click();
await expect(authedPage).toHaveURL(/\/home$/);
// store and hide the residual-state bug this covers. Highlights rather than
// Home, because Home is a search surface too and would keep owning "/".
await authedPage.getByRole('link', { name: 'Highlights' }).click();
await expect(authedPage).toHaveURL(/\/highlights$/);
await expect(authedPage.getByTestId('saved-search-input')).toHaveCount(0);

// Off the saved surface, "/" is the switcher again.
Expand All @@ -137,13 +138,15 @@ test.describe('Saved search', () => {
await authedPage.keyboard.press('Escape');
await expect(authedPage.getByRole('listbox')).toHaveCount(0);

// And coming back is a clean list, not the query from last time.
// And coming back is a clean list, not the query from last time — again
// client-side, so the stores are the same ones that held the query. Wait for
// the URL first: the assertion below must read the Saved list, not the page
// we are leaving, which still shows the same card for a frame.
await authedPage.getByRole('button', { name: 'Saved' }).first().click();
await expect(
authedPage
.getByLabel('Recently saved', { exact: true })
.getByRole('button', { name: 'An Afternoon at the Café' })
).toBeVisible({ timeout: 15_000 });
await expect(authedPage).toHaveURL(/\/saved$/);
await expect(authedPage.getByText('An Afternoon at the Café')).toBeVisible({
timeout: 15_000,
});
await expect(authedPage.getByTestId('saved-search-input')).toHaveCount(0);
});

Expand Down
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@types/node": "^26.3.0",
"@vite-pwa/sveltekit": "^1.1.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/lib/components/feed/FeedPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,8 @@
// exists at all: the global `/` shortcut asks the store whether there is a
// row to open. The view filters can't answer that — they survive unmount.
$effect(() => {
savedSearchStore.setSurfaceActive(isSavedView);
if (isSavedView) savedSearchStore.claimSurface('saved');
else savedSearchStore.releaseSurface('saved');
});

onDestroy(() => {
Expand All @@ -490,7 +491,7 @@
}
// Leaving the surface ends the search; a stale query must not come back
// pre-applied (and silently emptying the list) on the next visit.
savedSearchStore.setSurfaceActive(false);
savedSearchStore.releaseSurface('saved');
document.removeEventListener('visibilitychange', handleVisibilityChange);
});
</script>
Expand Down
Loading
Loading