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
4 changes: 2 additions & 2 deletions app.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
<title>Reader — your saved-link inbox</title>
<meta name="description" content="Save links from the web. Retrieve them and mark them read through ChatGPT." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<style>body{margin:0;background:#09090b;color:#fafafa;font-family:system-ui,sans-serif}.boot{min-height:100vh;display:grid;place-items:center;font-size:14px;color:#a1a1aa}</style>
<style>body{margin:0;background:#09090b;color:#fafafa;font-family:system-ui,sans-serif}.boot{min-height:100vh;display:grid;place-content:center;text-align:center;padding:0 32px}.boot h1{font-size:36px;margin:0 0 12px}.boot p{max-width:384px;line-height:1.5;color:#a1a1aa}.boot [role=status]{font-size:14px}</style>
<!-- Microsoft Clarity — session replay + heatmaps (additive; PostHog stays) -->
<script>(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src="https://www.clarity.ms/tag/y6budslpbi";y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,"clarity","script","y6budslpbi");window.clarity("set","project_id","reader");</script>
</head>
<body>
<div id="root"><div class="boot" role="status">Loading Reader…</div></div>
<div id="root"><main class="boot"><h1>Reader</h1><p>Save links from the web. Retrieve them and track what you’ve read through ChatGPT.</p><p role="status">Checking your session…</p></main></div>
<noscript>Enable JavaScript to use your Reader inbox.</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
Expand Down
2 changes: 1 addition & 1 deletion docs/development/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ MCP route tests verify protocol initialization, the three-tool catalog, retrieva

Source extraction tests cover SSRF rejection, bounded response streams, supported content types, and extraction failure.

Run `pnpm cf:build` and then `pnpm test:e2e` to check the built inbox and landing at phone, tablet, and desktop widths. Synthetic API responses cover URL capture, search, read/unread transitions, and the guest sign-in boundary. Screenshots stay in the ignored `.fleet-local/screenshots/` directory.
Run `pnpm cf:build` and then `pnpm test:e2e` to check the built inbox and landing at phone, tablet, and desktop widths. Committed null and synthetic auth fixtures cover the stable guest entry, pending-to-guest and pending-to-inbox transitions, public extension installation guidance, protected key management, and retained sign-in callbacks. Synthetic API responses also cover URL capture, search, and read/unread transitions. Browser tests block external requests and WebSockets and disable service workers; they never contact auth providers. Screenshots stay in the ignored `.fleet-local/screenshots/` and `test-results/` directories.

The extension has a separate test runner. API tests verify its retained key slot, authenticated URL/title capture, and rejection when no connection exists.

Expand Down
16 changes: 8 additions & 8 deletions landing-astro/src/content/reader.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@
"href": "/login"
},
"primary": {
"label": "Open your inbox",
"href": "/library",
"label": "Sign in to your inbox",
"href": "/login",
"icon": "ArrowUpRight"
}
},
Expand All @@ -66,8 +66,8 @@
"title": "Save a link. *Pick it up in ChatGPT.*",
"lede": "Keep the pages you want to come back to in one place. Add a URL, or save the current tab with the Chrome extension. ChatGPT can find your links, pull their content, and mark them read when you ask.",
"primary": {
"label": "Open your inbox",
"href": "/library",
"label": "Sign in to your inbox",
"href": "/login",
"icon": "ArrowUpRight"
},
"secondary": {
Expand Down Expand Up @@ -202,8 +202,8 @@
"title": "Bring one link you mean *to come back to.*",
"lede": "Sign in with Google, paste the address, and it is waiting in your inbox.",
"primary": {
"label": "Open your inbox",
"href": "/library",
"label": "Sign in to your inbox",
"href": "/login",
"icon": "ArrowUpRight"
},
"secondary": {
Expand All @@ -224,8 +224,8 @@
"title": "Product",
"links": [
{
"label": "Open your inbox",
"href": "/library"
"label": "Sign in to your inbox",
"href": "/login"
},
{
"label": "Connections",
Expand Down
2 changes: 1 addition & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export default defineConfig({
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: { baseURL: 'http://127.0.0.1:47192', trace: 'on-first-retry' },
use: { baseURL: 'http://127.0.0.1:47192', trace: 'on-first-retry', serviceWorkers: 'block' },
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{
Expand Down
30 changes: 30 additions & 0 deletions src/components/ExtensionConnectClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,36 @@ export function ExtensionConnectClient() {
</div>
</section>

{!user && (
<section className="rounded-xl border border-gray-800 bg-gray-900/70 p-5">
<h2 className="text-base font-semibold text-white">Install the Chrome extension</h2>
<ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-gray-400">
<li>
Download the extension ZIP from the{' '}
<a
href="https://github.com/Significant-Hobbies/reader/releases/tag/chrome-extension-v0.2.0"
className="text-blue-300 underline underline-offset-4 hover:text-blue-200"
>
Reader extension release
</a>{' '}
and unzip it.
</li>
<li>
In Chrome, open chrome://extensions, enable Developer mode, and choose Load
unpacked. Select the unzipped extension folder containing manifest.json.
</li>
<li>
Sign in to Reader, create an extension key here, then paste it into the extension
popup and click Connect. Use Save link to save the current page’s URL and title.
</li>
</ol>
<p className="mt-3 text-xs text-gray-500">
This is a local unpacked install. Extension keys connect to your Reader account;
ChatGPT uses a separate OAuth connection.
</p>
</section>
)}

{!user ? (
<section className="rounded-xl border border-gray-800 bg-gray-900/70 p-5">
<h2 className="text-base font-semibold text-white">Sign in first</h2>
Expand Down
10 changes: 2 additions & 8 deletions src/components/HomeClient.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowUpRight, Check, Loader2, Plus, Search, Undo2 } from 'lucide-react';
import { useState } from 'react';
import { Navigate } from 'react-router-dom';

import type { SavedLink } from '@/lib/links-db';
import { trackActivatedOnce, trackCoreAction } from '@/lib/analytics';
import { useAuth } from './AuthProvider';
import { Navbar } from './Navbar';
import { ReaderEntry } from './ReaderEntry';
import { Button } from './ui/button';
import { Input } from './ui/input';

Expand Down Expand Up @@ -73,13 +73,7 @@ export default function HomeClient() {
},
});

if (loading)
return (
<main className="grid min-h-screen place-items-center bg-[var(--gray-1)] text-[var(--gray-11)]">
<p role="status">Loading Reader…</p>
</main>
);
if (!user) return <Navigate to="/login" replace />;
if (loading || !user) return <ReaderEntry pending={loading} />;

return (
<div className="min-h-screen bg-[var(--gray-1)] text-[var(--gray-12)]">
Expand Down
39 changes: 39 additions & 0 deletions src/components/ReaderEntry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Link } from 'react-router-dom';

/** Public entry while auth resolves; pending never grants access or starts sign-in. */
export function ReaderEntry({ pending = false }: { pending?: boolean }) {
return (
<main
aria-busy={pending}
className="flex min-h-screen items-center justify-center bg-[#09090B] p-8"
>
<div className="w-full max-w-md rounded-xl border border-[var(--gray-5)] bg-[var(--gray-2)]/85 p-8 text-center shadow-[0_24px_90px_rgba(0,0,0,0.35)]">
<p className="mb-2 text-sm font-medium tracking-wide text-[var(--accent-8)] uppercase">
Saved-link inbox
</p>
<h1 className="text-4xl font-bold text-[var(--gray-12)]">Reader</h1>
<p className="mt-3 text-base text-[var(--gray-11)]">
Save links from the web. Retrieve them and track what you’ve read through ChatGPT.
</p>
{pending ? (
<p role="status" className="mt-8 text-sm text-[var(--gray-11)]">
Checking your session…
</p>
) : (
<Link
to="/login"
className="mt-8 flex min-h-11 items-center justify-center rounded-lg bg-[var(--accent-8)] px-4 font-medium text-[#09090B] hover:bg-[var(--accent-11)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--accent-8)]"
>
Sign in to your inbox
</Link>
)}
<Link
to="/extension"
className="mt-5 inline-block text-sm text-[var(--gray-11)] underline underline-offset-4 hover:text-[var(--gray-12)]"
>
Install and connect the Chrome extension
</Link>
</div>
</main>
);
}
16 changes: 2 additions & 14 deletions src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,17 @@ import type { ComponentType } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';

import RootLayout from './RootLayout';
import { ReaderEntry } from './components/ReaderEntry';

function lazyPage(module: () => Promise<{ default: ComponentType }>) {
return module().then((m) => ({ Component: m.default }));
}

function RouteLoading() {
return (
<main
className="flex min-h-screen items-center justify-center bg-black px-5 text-gray-100"
aria-busy="true"
>
<p role="status" className="text-sm text-gray-400">
Loading Reader…
</p>
</main>
);
}

export const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
hydrateFallbackElement: <RouteLoading />,
hydrateFallbackElement: <ReaderEntry pending />,
children: [
{ path: 'app', element: <Navigate to="/library" replace /> },
{ path: 'privacy', lazy: () => lazyPage(() => import('./pages/PrivacyPage')) },
Expand Down
35 changes: 35 additions & 0 deletions tests/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { test as base, expect } from '@playwright/test';

export const syntheticSession = {
user: {
id: 'alice',
name: 'Alice',
email: 'alice@example.com',
emailVerified: true,
image: null,
createdAt: '2026-10-09T00:00:00.000Z',
updatedAt: '2026-10-09T00:00:00.000Z',
},
session: {
id: 'session',
userId: 'alice',
token: 'synthetic',
expiresAt: '2099-01-01T00:00:00.000Z',
createdAt: '2026-10-09T00:00:00.000Z',
updatedAt: '2026-10-09T00:00:00.000Z',
},
};

export const test = base.extend({
context: async ({ context, baseURL }, use) => {
const origin = new URL(baseURL!).origin;
// Context routing covers pages/popups; only the local preview can receive traffic.
await context.route('**/*', (route) =>
new URL(route.request().url()).origin === origin ? route.continue() : route.abort()
);
await context.routeWebSocket('**/*', (socket) => socket.close());
await use(context);
},
});

export { expect };
129 changes: 126 additions & 3 deletions tests/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,134 @@
import { expect, test } from '@playwright/test';
test('sign-in is the only guest inbox action', async ({ page }) => {
import type { Page } from '@playwright/test';
import { expect, syntheticSession, test } from './fixtures';

async function publicAuth(page: Page) {
await page.route('**/api/auth/get-session', (route) => route.fulfill({ json: null }));
await page.route('**/api/auth/client-config', (route) =>
route.fulfill({ json: { googleClientId: null } })
);
}

function privateRequests(page: Page) {
const requests: string[] = [];
page.on('request', (request) => {
if (/\/api\/(links|keys)(?:[/?]|$)/u.test(request.url())) requests.push(request.url());
});
return requests;
}

async function render(page: Page, name: string) {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
true
);
await page.screenshot({ path: test.info().outputPath(`${name}.png`), fullPage: true });
}

test('guest inbox stays at a clear entry until explicit sign-in', async ({ page }) => {
await publicAuth(page);
const requests = privateRequests(page);
await page.goto('/library');
await expect(page.getByRole('link', { name: 'Sign in to your inbox' })).toBeVisible();
await expect(page).toHaveURL(/\/library$/u);
await expect(page.getByRole('heading', { name: 'Reader', exact: true })).toBeVisible();
await expect(page.getByText('Checking your session…')).toHaveCount(0);
await expect(page.getByText('Loading Reader…')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Add link' })).toHaveCount(0);
await render(page, 'guest-entry');
await page.getByRole('link', { name: 'Sign in to your inbox' }).click();
await expect(page).toHaveURL(/\/login$/u);
await expect(page.getByRole('button', { name: 'Sign in with Google' })).toBeVisible();
await expect(page.getByText('Save links from the web.', { exact: false })).toBeVisible();
await render(page, 'login');
expect(requests).toEqual([]);
});

for (const signedIn of [false, true]) {
test(`pending auth waits before resolving ${signedIn ? 'the inbox' : 'the guest entry'}`, async ({
page,
}) => {
const requests = privateRequests(page);
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/auth/get-session', async (route) => {
await ready;
await route.fulfill({ json: signedIn ? syntheticSession : null });
});
await page.route('**/api/links**', (route) =>
route.fulfill({ json: { items: [], total: 0, nextOffset: null } })
);
await page.goto('/library');
await expect(page.getByRole('status')).toHaveText('Checking your session…');
await expect(page.getByRole('heading', { name: 'Reader', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign in to your inbox' })).toHaveCount(0);
expect(requests).toEqual([]);
await expect(page).toHaveURL(/\/library$/u);
await render(page, 'pending-entry');
release();
if (signedIn) {
await expect(page.getByRole('heading', { name: 'Your links' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Nothing unread' })).toBeVisible();
expect(requests.length).toBeGreaterThan(0);
} else {
await expect(page.getByRole('link', { name: 'Sign in to your inbox' })).toBeVisible();
expect(requests).toEqual([]);
}
await expect(page).toHaveURL(/\/library$/u);
});
}

test('extension install and connection guidance is public; keys stay protected', async ({
page,
}) => {
await publicAuth(page);
const requests = privateRequests(page);
await page.goto('/extension');
await expect(page.getByRole('heading', { name: 'Install the Chrome extension' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Reader extension release' })).toHaveAttribute(
'href',
'https://github.com/Significant-Hobbies/reader/releases/tag/chrome-extension-v0.2.0'
);
await expect(page.getByText(/chrome:\/\/extensions/u)).toBeVisible();
await expect(page.getByText(/paste it into the extension popup/u)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sign in first' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Create extension key' })).toHaveCount(0);
await expect(page.getByRole('heading', { name: 'Existing extension keys' })).toHaveCount(0);
await render(page, 'guest-extension');
expect(requests).toEqual([]);
});

test('explicit sign-in retains each page callback and shows recoverable errors', async ({
page,
}) => {
await publicAuth(page);
const callbacks: string[] = [];
await page.route('**/api/auth/sign-in/social', (route) => {
const body = route.request().postDataJSON();
expect(body.provider).toBe('google');
callbacks.push(body.callbackURL);
return route.fulfill({ status: 400, json: { message: 'Synthetic sign-in failure' } });
});
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await expect(page.getByText('Failed to sign in. Please try again.')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in with Google' })).toBeEnabled();
await page.goto('/extension');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
await expect.poll(() => callbacks).toEqual(['/', '/extension']);
});

test('signed-in connections retain key management without creating a key on entry', async ({
page,
}) => {
await page.route('**/api/auth/get-session', (route) => route.fulfill({ json: syntheticSession }));
const methods: string[] = [];
await page.route('**/api/keys', (route) => {
methods.push(route.request().method());
return route.fulfill({ json: [] });
});
await page.goto('/extension');
await expect(page.getByRole('button', { name: 'Create extension key' })).toBeVisible();
await expect(page.getByText('No active extension keys.')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Sign in first' })).toHaveCount(0);
expect(methods).toEqual(['GET']);
});
Loading
Loading