From ba7a3732a628ee5644690ece3e5d46dd74c25fd2 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 10:24:54 +0000 Subject: [PATCH] fix: stabilize signed-out entry and expose extension installation --- app.html | 4 +- docs/development/testing.md | 2 +- landing-astro/src/content/reader.json | 16 +-- playwright.config.ts | 2 +- src/components/ExtensionConnectClient.tsx | 30 +++++ src/components/HomeClient.tsx | 10 +- src/components/ReaderEntry.tsx | 39 +++++++ src/router.tsx | 16 +-- tests/fixtures.ts | 35 ++++++ tests/login.spec.ts | 129 +++++++++++++++++++++- tests/mobile.spec.ts | 31 +----- 11 files changed, 251 insertions(+), 63 deletions(-) create mode 100644 src/components/ReaderEntry.tsx create mode 100644 tests/fixtures.ts diff --git a/app.html b/app.html index 68f63bd..7eb888c 100644 --- a/app.html +++ b/app.html @@ -7,12 +7,12 @@ Reader — your saved-link inbox - + -
Loading Reader…
+

Reader

Save links from the web. Retrieve them and track what you’ve read through ChatGPT.

Checking your session…

diff --git a/docs/development/testing.md b/docs/development/testing.md index a004343..e791a98 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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. diff --git a/landing-astro/src/content/reader.json b/landing-astro/src/content/reader.json index 53362ec..ff1d0eb 100644 --- a/landing-astro/src/content/reader.json +++ b/landing-astro/src/content/reader.json @@ -56,8 +56,8 @@ "href": "/login" }, "primary": { - "label": "Open your inbox", - "href": "/library", + "label": "Sign in to your inbox", + "href": "/login", "icon": "ArrowUpRight" } }, @@ -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": { @@ -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": { @@ -224,8 +224,8 @@ "title": "Product", "links": [ { - "label": "Open your inbox", - "href": "/library" + "label": "Sign in to your inbox", + "href": "/login" }, { "label": "Connections", diff --git a/playwright.config.ts b/playwright.config.ts index ef248ad..2f91b3b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'] } }, { diff --git a/src/components/ExtensionConnectClient.tsx b/src/components/ExtensionConnectClient.tsx index 7019d27..7897c05 100644 --- a/src/components/ExtensionConnectClient.tsx +++ b/src/components/ExtensionConnectClient.tsx @@ -165,6 +165,36 @@ export function ExtensionConnectClient() { + {!user && ( +
+

Install the Chrome extension

+
    +
  1. + Download the extension ZIP from the{' '} + + Reader extension release + {' '} + and unzip it. +
  2. +
  3. + In Chrome, open chrome://extensions, enable Developer mode, and choose Load + unpacked. Select the unzipped extension folder containing manifest.json. +
  4. +
  5. + 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. +
  6. +
+

+ This is a local unpacked install. Extension keys connect to your Reader account; + ChatGPT uses a separate OAuth connection. +

+
+ )} + {!user ? (

Sign in first

diff --git a/src/components/HomeClient.tsx b/src/components/HomeClient.tsx index f06d994..18fe135 100644 --- a/src/components/HomeClient.tsx +++ b/src/components/HomeClient.tsx @@ -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'; @@ -73,13 +73,7 @@ export default function HomeClient() { }, }); - if (loading) - return ( -
-

Loading Reader…

-
- ); - if (!user) return ; + if (loading || !user) return ; return (
diff --git a/src/components/ReaderEntry.tsx b/src/components/ReaderEntry.tsx new file mode 100644 index 0000000..88b672f --- /dev/null +++ b/src/components/ReaderEntry.tsx @@ -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 ( +
+
+

+ Saved-link inbox +

+

Reader

+

+ Save links from the web. Retrieve them and track what you’ve read through ChatGPT. +

+ {pending ? ( +

+ Checking your session… +

+ ) : ( + + Sign in to your inbox + + )} + + Install and connect the Chrome extension + +
+
+ ); +} diff --git a/src/router.tsx b/src/router.tsx index cf42bae..1b39950 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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 ( -
-

- Loading Reader… -

-
- ); -} - export const router = createBrowserRouter([ { path: '/', element: , - hydrateFallbackElement: , + hydrateFallbackElement: , children: [ { path: 'app', element: }, { path: 'privacy', lazy: () => lazyPage(() => import('./pages/PrivacyPage')) }, diff --git a/tests/fixtures.ts b/tests/fixtures.ts new file mode 100644 index 0000000..f2e20a6 --- /dev/null +++ b/tests/fixtures.ts @@ -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 }; diff --git a/tests/login.spec.ts b/tests/login.spec.ts index 2dd997e..d261b51 100644 --- a/tests/login.spec.ts +++ b/tests/login.spec.ts @@ -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((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']); }); diff --git a/tests/mobile.spec.ts b/tests/mobile.spec.ts index e3e5ee5..1630a12 100644 --- a/tests/mobile.spec.ts +++ b/tests/mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, syntheticSession, test } from './fixtures'; import { mkdir } from 'node:fs/promises'; test('the saved-link workflow works at every viewport', async ({ page }, testInfo) => { @@ -19,30 +19,7 @@ test('the saved-link workflow works at every viewport', async ({ page }, testInf createdAt: date, }, ]; - await page.route('https://**', (route) => route.abort()); - await page.route('**/api/auth/get-session', (route) => - route.fulfill({ - json: { - user: { - id: 'alice', - name: 'Alice', - email: 'alice@example.com', - emailVerified: true, - image: null, - createdAt: date, - updatedAt: date, - }, - session: { - id: 'session', - userId: 'alice', - token: 'synthetic', - expiresAt: '2099-01-01T00:00:00.000Z', - createdAt: date, - updatedAt: date, - }, - }, - }) - ); + await page.route('**/api/auth/get-session', (route) => route.fulfill({ json: syntheticSession })); await page.route('**/api/links**', async (route) => { const request = route.request(); if (request.method() === 'POST') { @@ -90,7 +67,6 @@ test('the saved-link workflow works at every viewport', async ({ page }, testInf }); test('landing states the narrow product and fits the viewport', async ({ page }, testInfo) => { - await page.route('https://**', (route) => route.abort()); await page.goto('/'); await expect( page.getByRole('heading', { name: 'Save a link. Pick it up in ChatGPT.' }) @@ -98,6 +74,9 @@ test('landing states the narrow product and fits the viewport', async ({ page }, expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe( true ); + const signInLinks = page.getByRole('link', { name: 'Sign in to your inbox' }); + expect(await signInLinks.count()).toBeGreaterThan(0); + for (const link of await signInLinks.all()) await expect(link).toHaveAttribute('href', '/login'); await mkdir('.fleet-local/screenshots', { recursive: true }); await page.screenshot({ path: `.fleet-local/screenshots/landing-${testInfo.project.name}.png`,