diff --git a/scripts/offline-monitor-subscription.test.mjs b/scripts/offline-monitor-subscription.test.mjs new file mode 100644 index 0000000..2d65291 --- /dev/null +++ b/scripts/offline-monitor-subscription.test.mjs @@ -0,0 +1,68 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { build } from 'esbuild'; +import { pathToFileURL } from 'node:url'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register(); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const React = await import('react'); +const { render, act, cleanup } = await import('@testing-library/react'); +const { ConvexProvider } = await import('convex/react'); +const { getFunctionName } = await import('convex/server'); +const state = globalThis.__monitorSubscription = { + token: null, accountId: '', + compaction: { lists: [], items: [], operations: [], revokedListIds: [] }, + reconciled: [], drafts: [], +}; +await build({ entryPoints: ['src/components/offline/OfflineAccessMonitor.tsx'], outfile: 'tmp/monitor-subscription.mjs', bundle: true, platform: 'node', format: 'esm', jsx: 'automatic', external: ['react', 'react/jsx-runtime', 'convex/react', 'convex/server', 'convex/values'], plugins: [{ name: 'monitor-context', setup(b) { + b.onResolve({ filter: /\/useAuth$|\/useOffline$|\/offline$|\/noteDrafts$|\/downloadCache$/ }, args => ({ path: args.path, namespace: 'fixture' })); + b.onLoad({ filter: /.*/, namespace: 'fixture' }, ({ path }) => ({ contents: + path.endsWith('/useAuth') ? 'export const useAuth=()=>globalThis.__monitorSubscription;' : + path.endsWith('/useOffline') ? 'export const useOffline=()=>globalThis.__monitorSubscription;' : + path.endsWith('/offline') ? 'export const reconcileOfflineAccess=(account,value)=>globalThis.__monitorSubscription.reconciled.push({account,value});' : + path.endsWith('/downloadCache') ? 'export const purgeAppDownloadCaches=async()=>{};' : + `export const draftResources=()=>globalThis.__monitorSubscription.drafts; + export const reconcileDraftAccess=()=>{}; export const subscribeDrafts=()=>()=>{};` })); +} }] }); +const { OfflineAccessMonitor } = await import(pathToFileURL(`${process.cwd()}/tmp/monitor-subscription.mjs`)); + +// Keep the actual Convex React hooks. Stubbing useQueries hides its requirement +// for stable request identities and missed the production infinite render loop. +test('real Convex subscriptions survive sign-in, batched access, token changes and sign-out', async () => { + const active = new Map(); + const results = new Map(); + const client = { watchQuery(query, args) { + const name = getFunctionName(query); + const key = JSON.stringify([name, args]); + if (!results.has(key)) results.set(key, name === 'items:getOfflineAccount' + ? { accountId: state.accountId, did: 'did:owner' } : []); + return { + localQueryResult: () => results.get(key), journal: () => undefined, + onUpdate(listener) { active.set(key, { name, args, listener }); return () => active.delete(key); }, + }; + } }; + const tree = () => React.createElement(ConvexProvider, { client }, React.createElement(OfflineAccessMonitor)); + const view = render(tree()); + try { + assert.equal(active.size, 0); + state.accountId = 'account-one'; state.token = 'first-session'; + state.compaction = { lists: [{ _id: 'L' }], items: [{ _id: 'I', listId: 'L' }], operations: [], revokedListIds: [] }; + state.drafts = [{ kind: 'note', id: 'L', documentKey: 'did:owner:note:L' }]; + view.rerender(tree()); + assert.equal(active.size, 4, 'identity, list, item and draft subscriptions mount'); + assert.ok([...active.values()].every(q => q.args.authToken === 'first-session')); + const listQuery = [...active.entries()].find(([, q]) => q.name === 'items:getOfflineAccess' && q.args.listIds.length); + const denial = [{ listId: 'L', canRead: false, canEdit: false, checkedAt: 10, missingItemIds: [] }]; + await act(async () => { results.set(listQuery[0], denial); listQuery[1].listener(); }); + assert.ok(state.reconciled.some(r => r.account === 'account-one' && r.value === denial), 'revocation still reaches reconciliation'); + state.token = 'renewed-session'; view.rerender(tree()); + assert.equal(active.size, 4); + assert.ok([...active.values()].every(q => q.args.authToken === 'renewed-session')); + state.accountId = 'account-two'; state.token = 'other-session'; view.rerender(tree()); + assert.equal(active.size, 4, 'account switch resubscribes identity, list, item and draft'); + assert.ok([...active.values()].every(q => q.args.authToken === 'other-session')); + state.accountId = ''; state.token = null; view.rerender(tree()); + assert.equal(active.size, 0, 'sign-out removes all protected subscriptions'); + } finally { view.unmount(); cleanup(); } + assert.equal(active.size, 0); +}); diff --git a/src/components/offline/OfflineAccessMonitor.tsx b/src/components/offline/OfflineAccessMonitor.tsx index 3126fbe..1871baf 100644 --- a/src/components/offline/OfflineAccessMonitor.tsx +++ b/src/components/offline/OfflineAccessMonitor.tsx @@ -1,5 +1,5 @@ import { purgeAppDownloadCaches } from '../../lib/downloadCache'; -import { useEffect, useReducer } from 'react'; +import { useEffect, useMemo, useReducer } from 'react'; import { useOffline } from '../../hooks/useOffline'; import { useQueries } from 'convex/react'; import { useAuth } from '../../hooks/useAuth'; @@ -9,10 +9,12 @@ import { reconcileOfflineAccess } from '../../lib/offline'; import { draftResources, reconcileDraftAccess, subscribeDrafts } from '../../lib/noteDrafts'; type ItemLocator = { itemId: Id<'items'>; listId: Id<'lists'> }; -function AccessBatch({ accountId, listIds, items = [] }: { accountId: string; listIds: Id<'lists'>[]; items?: ItemLocator[] }) { +const noItems: ItemLocator[] = []; +function AccessBatch({ accountId, listIds, items = noItems }: { accountId: string; listIds: Id<'lists'>[]; items?: ItemLocator[] }) { const { token } = useAuth(); // Errors are values: session expiry must not unmount independent recovery. - const { access } = useQueries(token ? { access: { query: api.items.getOfflineAccess, args: { listIds, items, authToken: token } } } : {}); + const queries = useMemo((): Parameters[0] => token ? { access: { query: api.items.getOfflineAccess, args: { listIds, items, authToken: token } } } : {}, [token, listIds, items]); + const { access } = useQueries(queries); useEffect(() => { if (access && !(access instanceof Error)) void reconcileOfflineAccess(accountId, access); }, [accountId, access]); @@ -20,7 +22,8 @@ function AccessBatch({ accountId, listIds, items = [] }: { accountId: string; li } function DraftBatch({ resources }: { resources: ReturnType }) { const { token } = useAuth(); - const { access } = useQueries(token ? { access: { query: api.items.getOfflineDraftAccess, args: { resources: resources.map(({ kind, id }) => ({ kind, id })), authToken: token } } } : {}); + const queries = useMemo((): Parameters[0] => token ? { access: { query: api.items.getOfflineDraftAccess, args: { resources: resources.map(({ kind, id }) => ({ kind, id })), authToken: token } } } : {}, [token, resources]); + const { access } = useQueries(queries); const keys = JSON.stringify(resources); useEffect(() => { if (!access || access instanceof Error) return; @@ -37,7 +40,9 @@ const batches = (entries: T[]) => Array.from({ length: Math.ceil(entries.len export function OfflineAccessMonitor() { const { accountId, compaction, isOnline } = useOffline(); const { token } = useAuth(); - const { identity } = useQueries(token ? { identity: { query: api.items.getOfflineAccount, args: { authToken: token } } } : {}); + // Convex useQueries requires stable request identity, including when signed out. + const queries = useMemo((): Parameters[0] => token ? { identity: { query: api.items.getOfflineAccount, args: { authToken: token } } } : {}, [token]); + const { identity } = useQueries(queries); const [, refresh] = useReducer(value => value + 1, 0); useEffect(() => subscribeDrafts(refresh), []); const revokedKey = [...compaction.revokedListIds ?? []].sort().join(','); diff --git a/tests/fixtures/server.mjs b/tests/fixtures/server.mjs index ca8bcdd..e242c9b 100644 --- a/tests/fixtures/server.mjs +++ b/tests/fixtures/server.mjs @@ -41,7 +41,19 @@ function query(account, path, args) { acknowledgments: account.receipts.filter(r => args.operationIds.includes(r.operationId)), sequence: account.sequence, }; - case 'items:getOfflineAccount': return { accountId: user.turnkeySubOrgId }; + case 'items:getOfflineAccount': return { accountId: user.turnkeySubOrgId, did: user.did }; + case 'items:getOfflineAccess': return [...new Set([...args.listIds, ...(args.items ?? []).map(i => i.listId)])].map(listId => { + const canRead = account.lists.some(l => l._id === listId); + const locators = (args.items ?? []).filter(i => i.listId === listId); + const present = id => canRead && account.items.some(i => i._id === id && i.listId === listId); + return { listId, canRead, canEdit: canRead, checkedAt: Date.now(), + presentItemIds: locators.filter(i => present(i.itemId)).map(i => i.itemId), + missingItemIds: locators.filter(i => !present(i.itemId)).map(i => i.itemId) }; + }); + case 'items:getOfflineDraftAccess': return args.resources.map(resource => ({ ...resource, checkedAt: Date.now(), + canEdit: resource.kind === 'note' ? account.lists.some(l => l._id === resource.id) : account.items.some(i => i._id === resource.id && account.lists.some(l => l._id === i.listId)) })); + case 'listGrants:getMyListAccess': return { ownerDid: user.did, role: account.lists.some(l => l._id === args.listId) ? 'owner' : null }; + case 'users:getMyPublicDisplayName': return { displayName: null }; case 'billing:getUserPlan': return 'free'; case 'billing:getUserSubscription': return null; case 'publication:getPublicationStatus': return account.published ? { diff --git a/tests/landing.e2e.ts b/tests/landing.e2e.ts index 7a302af..56ee646 100644 --- a/tests/landing.e2e.ts +++ b/tests/landing.e2e.ts @@ -56,12 +56,12 @@ test.describe("Landing page", () => { await expect(screen.getByText("Made carefully, by humans and agents.")).toBeVisible(); }); - test("9. OG image meta tag points to /og-image.png", async ({ browser, scenario }) => { + test("9. OG image meta tag points to /og-image-boop.png", async ({ browser, scenario }) => { await scenario.open("/", { authenticated: false }); const ogImage = browser.locator('meta[property="og:image"]'); - await expect(ogImage).toHaveAttribute("content", /\/og-image\.png/); + await expect(ogImage).toHaveAttribute("content", /\/og-image-boop\.png/); const twitterImage = browser.locator('meta[name="twitter:image"]'); - await expect(twitterImage).toHaveAttribute("content", /\/og-image\.png/); + await expect(twitterImage).toHaveAttribute("content", /\/og-image-boop\.png/); }); test("10. no fabricated user-count stats are visible", async ({ browser, scenario }) => { diff --git a/tests/share-modal.e2e.ts b/tests/share-modal.e2e.ts index 958d252..f8c6512 100644 --- a/tests/share-modal.e2e.ts +++ b/tests/share-modal.e2e.ts @@ -22,23 +22,23 @@ test.describe("Share / publish flow (POO-14)", () => { ).toBeVisible({ timeout: 5000 }); await expect( - screen.getByRole("button", { name: /Publish to Share/i }), + screen.getByRole("button", { name: /Publish publicly/i }), ).toBeVisible(); }); - test("3. Publish to Share triggers publishList mutation and updates UI", async ({ screen, scenario }) => { + test("3. Publish publicly triggers publishList mutation and updates UI", async ({ screen, scenario }) => { await openList(scenario, screen, { published: false }); await screen.getByRole("button", { name: "Share", exact: false }).click({ timeout: 10000 }); - await expect(screen.getByRole("button", { name: /Publish to Share/i })).toBeVisible({ timeout: 5000 }); + await expect(screen.getByRole("button", { name: /Publish publicly/i })).toBeVisible({ timeout: 5000 }); - await screen.getByRole("button", { name: /Publish to Share/i }).click(); + await screen.getByRole("button", { name: /Publish publicly/i }).click(); await expect( - screen.getByRole("heading", { name: /Shared List/i }), + screen.getByRole("heading", { name: /Share list/i }), ).toBeVisible({ timeout: 10000 }); - await expect(screen.getByText("This list is shared")).toBeVisible({ timeout: 5000 }); + await expect(screen.getByText("This list is published publicly")).toBeVisible({ timeout: 5000 }); }); test("4. already-published list shows share link in modal", async ({ screen, browser, scenario }) => { @@ -47,7 +47,7 @@ test.describe("Share / publish flow (POO-14)", () => { await screen.getByRole("button", { name: "Share", exact: false }).click({ timeout: 10000 }); await expect( - screen.getByRole("heading", { name: /Shared List/i }), + screen.getByRole("heading", { name: /Share list/i }), ).toBeVisible({ timeout: 5000 }); await expect(browser.locator('input[readonly]').first()).toBeVisible({ timeout: 5000 }); @@ -55,17 +55,17 @@ test.describe("Share / publish flow (POO-14)", () => { await expect(screen.getByRole("button", { name: /Copy/i })).toBeVisible(); }); - test("5. Stop sharing button is visible for published lists", async ({ screen, scenario }) => { + test("5. Unpublish button is visible for published lists", async ({ screen, scenario }) => { await openList(scenario, screen, { published: true }); await screen.getByRole("button", { name: "Share", exact: false }).click({ timeout: 10000 }); await expect( - screen.getByRole("heading", { name: /Shared List/i }), + screen.getByRole("heading", { name: /Share list/i }), ).toBeVisible({ timeout: 5000 }); await expect( - screen.getByRole("button", { name: /Stop sharing/i }), + screen.getByRole("button", { name: /Unpublish/i }), ).toBeVisible(); }); @@ -74,21 +74,21 @@ test.describe("Share / publish flow (POO-14)", () => { await screen.getByRole("button", { name: "Share", exact: false }).click({ timeout: 10000 }); await expect( - screen.getByRole("heading", { name: /Shared List/i }), + screen.getByRole("heading", { name: /Share list/i }), ).toBeVisible({ timeout: 5000 }); await screen.getByRole("button", { name: "Done" }).click(); await expect( - screen.getByRole("heading", { name: /Shared List/i }), + screen.getByRole("heading", { name: /Share list/i }), ).not.toBeVisible({ timeout: 3000 }); }); - test("7. list detail page shows Shared badge when list is published", async ({ screen, scenario }) => { + test("7. list detail page shows public badge when list is published", async ({ screen, scenario }) => { await openList(scenario, screen, { published: true }); await expect( - screen.getByText("shared", { exact: true }), + screen.getByText("public", { exact: true }), ).toBeVisible({ timeout: 10000 }); }); }); diff --git a/tests/sharing.e2e.ts b/tests/sharing.e2e.ts index 1cd5ed8..3016126 100644 --- a/tests/sharing.e2e.ts +++ b/tests/sharing.e2e.ts @@ -15,7 +15,7 @@ test.describe("Sharing flow", () => { await screen.getByRole("button", { name: "Share" }).click(); await expect( - screen.getByRole("heading", { name: "🔗 Share List" }), + screen.getByRole("heading", { name: "Share list" }), ).toBeVisible({ timeout: 5000 }); }); @@ -24,11 +24,11 @@ test.describe("Sharing flow", () => { await screen.getByRole("button", { name: "Share" }).click(); await expect( - screen.getByRole("heading", { name: "🔗 Share List" }), + screen.getByRole("heading", { name: "Share list" }), ).toBeVisible({ timeout: 5000 }); await expect( - screen.getByRole("button", { name: "Publish to Share" }), + screen.getByRole("button", { name: "Publish publicly" }), ).toBeVisible({ timeout: 5000 }); }); @@ -37,13 +37,13 @@ test.describe("Sharing flow", () => { await screen.getByRole("button", { name: "Share" }).click(); await expect( - screen.getByRole("heading", { name: "🔗 Share List" }), + screen.getByRole("heading", { name: "Share list" }), ).toBeVisible({ timeout: 5000 }); await screen.getByRole("button", { name: "Done" }).click(); await expect( - screen.getByRole("heading", { name: "🔗 Share List" }), + screen.getByRole("heading", { name: "Share list" }), ).not.toBeVisible(); }); }); @@ -53,7 +53,7 @@ test.describe("Join flow", () => { await app.open("/join/invalid-list-id/invalid-token"); await expect( - screen.getByRole("heading", { name: "Invite Links No Longer Supported" }), + screen.getByRole("heading", { name: "This legacy invite link is no longer supported" }), ).toBeVisible(); }); });