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
173 changes: 173 additions & 0 deletions desktop/electron/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,179 @@ test('read: Cite keeps Scholar and OpenAlex provenance separate', async () => {
}
});

test('read: Discovery uses contextual saved and recent search navigation', async () => {
await dismissConnectModal();
const originals = await page.evaluate(() => {
const libraryKey = 'termipod.library.v1';
const historyKey = 'termipod.discover.history.v1';
const originalLibrary = localStorage.getItem(libraryKey);
const originalHistory = localStorage.getItem(historyKey);
localStorage.setItem(libraryKey, JSON.stringify({
references: [{
id: 'ref-discovery-rail',
type: 'article',
title: 'Library rail fixture',
authors: ['TermiPod'],
tags: ['library-only-tag'],
collectionIds: ['collection-only'],
notes: '',
addedAt: Date.now(),
dirty: false,
attachments: [],
}],
collections: [{ id: 'collection-only', name: 'Library-only collection' }],
}));
localStorage.setItem(historyKey, JSON.stringify({
version: 1,
saved: [{
id: 'saved-e2e',
name: 'Saved core query',
query: 'saved discovery query',
sourceId: 'core',
authorFilter: 'Researcher',
yearFrom: '2024',
yearTo: '',
sort: 'newest',
findPdfs: false,
savedAt: Date.now() - 2000,
}],
recent: [{
id: 'recent-e2e',
query: 'recent discovery query',
sourceId: 'openalex',
authorFilter: '',
yearFrom: '',
yearTo: '',
sort: 'relevance',
findPdfs: true,
ranAt: Date.now() - 1000,
resultCount: 17,
}],
}));
return { originalLibrary, originalHistory };
});

try {
await page.reload({ waitUntil: 'domcontentloaded' });
await dismissConnectModal();
await page.locator('[data-job="read"]').click();
await page.getByRole('button', { name: 'Discover', exact: true }).click();

const rail = page.locator('.discover-nav');
await expect(rail).toBeVisible();
await expect(rail.getByText('Saved core query', { exact: true })).toBeVisible();
await expect(rail.getByText('recent discovery query', { exact: true })).toBeVisible();
await expect(rail.getByText('Recent searches', { exact: true })).toBeVisible();
await expect(page.getByText('Library-only collection', { exact: true })).toHaveCount(0);
await expect(page.getByText('library-only-tag', { exact: true })).toHaveCount(0);

await rail.locator('.discover-nav-row').filter({ hasText: 'Saved core query' }).locator('.discover-nav-main').click();
await expect(page.locator('.discover-input')).toHaveValue('saved discovery query');
await expect(page.locator('.discover-src.active')).toHaveText('CORE');
await expect(page.getByRole('button', { name: 'Remove saved search', exact: true })).toBeVisible();

await page.locator('.discover-input').fill('a newly saved query');
await page.locator('.discover-save-search').click();
await expect(rail.getByText('a newly saved query', { exact: true })).toBeVisible();
await expect(page.locator('.discover-save-search')).toHaveAttribute(
'aria-label',
'Remove saved search',
);
await page.locator('.discover-save-search').click();
await expect(rail.getByText('a newly saved query', { exact: true })).toHaveCount(0);

await rail.getByRole('button', { name: 'Clear', exact: true }).click();
await expect(rail.getByText('recent discovery query', { exact: true })).toHaveCount(0);
const persisted = await page.evaluate(() => JSON.parse(localStorage.getItem('termipod.discover.history.v1') ?? '{}'));
expect(persisted.recent).toEqual([]);
expect(persisted.saved).toHaveLength(1);
} finally {
await page.evaluate(({ originalLibrary, originalHistory }) => {
if (originalLibrary === null) localStorage.removeItem('termipod.library.v1');
else localStorage.setItem('termipod.library.v1', originalLibrary);
if (originalHistory === null) localStorage.removeItem('termipod.discover.history.v1');
else localStorage.setItem('termipod.discover.history.v1', originalHistory);
}, originals);
await page.reload({ waitUntil: 'domcontentloaded' });
}
});

test('read: Discovery monitoring exposes updates, subscriptions, schedules, and collection recommendations', async () => {
await dismissConnectModal();
const originals = await page.evaluate(() => {
const keys = ['termipod.library.v1', 'termipod.discover.history.v1', 'termipod.discover.monitor.v1'];
const values = Object.fromEntries(keys.map((key) => [key, localStorage.getItem(key)]));
localStorage.setItem('termipod.library.v1', JSON.stringify({
collections: [{ id: 'collection-monitor', name: 'Graph research' }],
references: [{
id: 'reference-monitor', type: 'article', title: 'Graph learning for molecules', authors: ['Ada Researcher'],
venue: 'Graph Journal', rating: 5, topics: ['Graph learning'], tags: ['molecules'],
collectionIds: ['collection-monitor'], notes: '', addedAt: Date.now(), dirty: false, attachments: [],
}],
}));
localStorage.setItem('termipod.discover.history.v1', JSON.stringify({
version: 1,
recent: [],
saved: [{
id: 'saved-monitor', name: 'Saved graph query', query: 'graph learning', sourceId: 'openalex',
authorFilter: '', yearFrom: '', yearTo: '', sort: 'newest', findPdfs: false, savedAt: Date.now(),
}],
}));
localStorage.setItem('termipod.discover.monitor.v1', JSON.stringify({
version: 1,
subscriptions: [{
id: 'subscription-monitor', kind: 'topic', label: 'Graph learning', value: 'graph learning',
sourceId: 'openalex', cadence: 'weekly', createdAt: Date.now(),
}],
updates: [{
id: 'update-monitor', originType: 'subscription', originId: 'subscription-monitor',
originLabel: 'Graph learning', arrivedAt: Date.now(),
paper: { paperId: 'paper-monitor', title: 'A new graph paper', authors: ['A. Author'], year: 2026, venue: 'Graph Journal' },
}],
runs: {
'subscription:subscription-monitor': { lastRunAt: Date.now(), seen: ['id:paper-monitor'] },
},
lastRefreshAt: Date.now(),
}));
return values;
});

try {
await page.reload({ waitUntil: 'domcontentloaded' });
await dismissConnectModal();
await page.locator('[data-job="read"]').click();
await page.getByRole('button', { name: 'Discover', exact: true }).click();

const rail = page.locator('.discover-nav');
await expect(rail.getByRole('button', { name: /Updates/ })).toContainText('1');
await rail.getByRole('button', { name: /Updates/ }).click();
await expect(page.getByRole('heading', { name: 'Updates' })).toBeVisible();
await expect(page.getByText('A new graph paper', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Mark read', exact: true }).click();
await expect(rail.locator('.discover-nav-count')).toHaveCount(0);

await rail.getByRole('button', { name: 'Following', exact: true }).click();
await expect(page.getByText('Graph learning', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Monitors', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Scheduled saved searches' })).toBeVisible();
await expect(page.getByText('Saved graph query', { exact: true })).toBeVisible();

await rail.getByRole('button', { name: 'For you', exact: true }).click();
await page.getByLabel('Seed collection').selectOption('collection-monitor');
await expect(page.getByText('Seeded by 1 collection items', { exact: true })).toBeVisible();
await expect(page.locator('.discovery-recommend-explain')).toContainText('graph');
await expect(page.locator('.discovery-recommend-explain')).toContainText('molecules');
} finally {
await page.evaluate((values) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
}
}, originals);
await page.reload({ waitUntil: 'domcontentloaded' });
}
});

test('read: PDF frequent actions stay visible and the outline folds by level', async () => {
await dismissConnectModal();
const fixture = await page.evaluate(async ({ bytes }) => {
Expand Down
93 changes: 92 additions & 1 deletion desktop/electron/src/ipc/discovery.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { discoveryHandlers } from './discovery.ts';
import { discoveryHandlers, fetchPublicJson, socialUrl } from './discovery.ts';

test('SerpAPI transport fixes the Scholar endpoint and returns structured JSON', async () => {
const originalFetch = globalThis.fetch;
Expand Down Expand Up @@ -82,3 +82,94 @@ test('SerpAPI citations transport rejects malformed ids before network access',
/valid cites id is required/,
);
});

test('RSS transport accepts a public HTTP feed and returns bounded text', async () => {
const originalFetch = globalThis.fetch;
let requested = '';
globalThis.fetch = (async (input) => {
requested = String(input);
return new Response('<rss><channel><title>Research</title></channel></rss>', { status: 200 });
}) as typeof fetch;
try {
const out = await discoveryHandlers.discovery_fetch_feed(
{ url: 'https://93.184.216.34/research.xml', proxy: null },
{} as never,
);
assert.equal(requested, 'https://93.184.216.34/research.xml');
assert.deepEqual(out, {
url: 'https://93.184.216.34/research.xml',
text: '<rss><channel><title>Research</title></channel></rss>',
});
} finally {
globalThis.fetch = originalFetch;
}
});

test('RSS transport refuses loopback and private-network URLs before fetch', async () => {
let fetched = false;
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
fetched = true;
return new Response('unexpected');
}) as typeof fetch;
try {
for (const url of ['http://127.0.0.1/feed', 'http://10.0.0.2/rss', 'file:///tmp/feed.xml']) {
await assert.rejects(
async () => discoveryHandlers.discovery_fetch_feed({ url, proxy: null }, {} as never),
/not allowed|only HTTP/,
);
}
assert.equal(fetched, false);
} finally {
globalThis.fetch = originalFetch;
}
});

test('RSS transport revalidates redirects so a public feed cannot bounce into loopback', async () => {
const originalFetch = globalThis.fetch;
let requests = 0;
globalThis.fetch = (async () => {
requests += 1;
return new Response(null, { status: 302, headers: { location: 'http://127.0.0.1/private-feed' } });
}) as typeof fetch;
try {
await assert.rejects(
async () => discoveryHandlers.discovery_fetch_feed({ url: 'https://93.184.216.34/feed', proxy: null }, {} as never),
/private hosts are not allowed/,
);
assert.equal(requests, 1);
} finally {
globalThis.fetch = originalFetch;
}
});

test('social connector URLs keep X credentials out of the query and apply monitor filters', () => {
const x = socialUrl('x-author', '@researcher', 'en', true);
assert.ok(x !== null);
assert.equal(x.origin + x.pathname, 'https://api.x.com/2/tweets/search/recent');
assert.equal(x.searchParams.get('query'), 'from:researcher -is:retweet lang:en');
assert.equal(x.searchParams.has('api_key'), false);

const bluesky = socialUrl('bluesky-query', 'agent memory', 'zh', true);
assert.ok(bluesky !== null);
assert.equal(bluesky.searchParams.get('q'), 'agent memory');
assert.equal(bluesky.searchParams.get('lang'), 'zh');
});

test('credentialed social fetch refuses a cross-origin redirect before leaking the bearer token', async () => {
const originalFetch = globalThis.fetch;
let requests = 0;
globalThis.fetch = (async () => {
requests += 1;
return new Response(null, { status: 302, headers: { location: 'https://198.51.100.1/steal' } });
}) as typeof fetch;
try {
await assert.rejects(
async () => fetchPublicJson('https://93.184.216.34/posts', null, 'social', { Authorization: 'Bearer secret' }),
/cross-origin redirect refused/,
);
assert.equal(requests, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
Loading
Loading