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
68 changes: 68 additions & 0 deletions scripts/offline-monitor-subscription.test.mjs
Original file line number Diff line number Diff line change
@@ -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'));
Comment thread
pullfrog[bot] marked this conversation as resolved.
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);
});
15 changes: 10 additions & 5 deletions src/components/offline/OfflineAccessMonitor.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,18 +9,21 @@ 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<typeof useQueries>[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]);
return null;
}
function DraftBatch({ resources }: { resources: ReturnType<typeof draftResources> }) {
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<typeof useQueries>[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;
Expand All @@ -37,7 +40,9 @@ const batches = <T,>(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<typeof useQueries>[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(',');
Expand Down
14 changes: 13 additions & 1 deletion tests/fixtures/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? {
Expand Down
6 changes: 3 additions & 3 deletions tests/landing.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
28 changes: 14 additions & 14 deletions tests/share-modal.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -47,25 +47,25 @@ 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 });

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();
});

Expand All @@ -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 });
});
});
12 changes: 6 additions & 6 deletions tests/sharing.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});

Expand All @@ -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 });
});

Expand All @@ -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();
});
});
Expand All @@ -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();
});
});
Loading