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
1 change: 1 addition & 0 deletions causestarter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ Then **restart Grok** so MCP tools load.
| `cause-view-strip` | Union / conjunction counts over selected statements |
| `plank-in-totals-N` | Include/exclude the Nth statement from those totals (view only) |
| `view-count-any` / `view-count-all` / `view-count-none-disagreed` | The counts themselves |
| `cause-keep-on-device` / `cause-remove-from-device` | Bookmark / remove a published cause you do not organize |

On **localhost**, Connect only lists Hardhat accounts (no MetaMask). Use **Hardhat #0** for funded local txs.

Expand Down
2 changes: 1 addition & 1 deletion causestarter/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ open **if they stay listed here**.

- [ ] Safety filter is MVP/heuristic + LLM policy text — not legal-grade; version/align with operator/legal specs later.
- [ ] Unpublished draft state still in `localStorage` only — multi-device recovery of *drafts* later (published rosters are on chain; published *bookmarks* follow the wallet `bookmarked-causes` ref).
- [ ] Cause bookmarks: Playwright for keep/remove + reconnect hydrating from `bookmarked-causes`. Union-sync can resurrect a bookmark removed on another device (no tombstones). Hydrated list cards may show plank CIDs as text until the cause page is opened. Bookmarking is a public wallet `updateRef` with only the list-page disclaimer.
- [ ] Cause bookmarks: `bookmarked-causes` is a public wallet `updateRef`; the only user-facing warning is the Causes list-page disclaimer. Keep/remove, reconnect hydrate, and tombstoned union-sync (a later keep can restore) are in place; Playwright covers keep/remove + reconnect.
- [ ] Statement `bookmarks` ref is still reserved infrastructure only — no CauseStarter (or main `ui`) surface for remembering a statement without signing it.
- [ ] No Privy path / full parity with main `ui` wallet story yet.
- [ ] Product: how CauseStarter ranks vs other domains in nav/marketing once it’s “the main thing.”
Expand Down
138 changes: 138 additions & 0 deletions causestarter/e2e/bookmarks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { expect, test, type Page } from '@playwright/test'

function appPath(path: string): string {
const hashMode = process.env.CAUSESTARTER_HASH_ROUTING !== '0'
if (!hashMode) return path
const normalized = path.startsWith('/') ? path : `/${path}`
return `/#${normalized === '/' ? '/' : normalized}`
}

async function connectHardhat(page: Page, account: number) {
const shellWallet = page.getByRole('banner').getByTestId('wallet-connect-button')
await shellWallet.click()
await expect(page.getByTestId('wallet-account-menu')).toBeVisible()
await page.getByTestId(`wallet-hardhat-${account}`).click()
await expect(shellWallet).toContainText(`Hardhat #${account}`, {
timeout: 15_000,
})
}

async function startCause(page: Page) {
await page.getByTestId('nav-causes').click()
await page.getByTestId('causes-start-cause').click()
await expect(page.getByTestId('cause-detail-page')).toBeVisible({ timeout: 10_000 })
}

async function clearBrowserStorage(page: Page) {
await page.evaluate(() => {
try {
localStorage.clear()
sessionStorage.clear()
} catch {
// ignore
}
})
}

function documentHasSlug(value: string, slug: string, present: boolean): boolean {
try {
const parsed = JSON.parse(value) as { causes?: Array<{ slug?: string }>; removed?: Array<{ slug?: string }> }
const causes = parsed.causes?.some((row) => row.slug === slug) ?? false
const removed = parsed.removed?.some((row) => row.slug === slug) ?? false
return present ? causes && !removed : removed && !causes
} catch {
return false
}
}

async function waitForWalletBookmarkWrite(page: Page, slug: string, present: boolean) {
const indexerUrl = process.env.INDEXER_URL ?? 'http://localhost:42069'
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
const res = await page.request.post(`${indexerUrl}/graphql`, {
data: {
query: `{ mutableRefss(where: { name: "bookmarked-causes" }, limit: 20) { items { value } } }`,
},
}).catch(() => null)
const body = res && res.ok()
? await res.json() as { data?: { mutableRefss?: { items?: Array<{ value: string }> } } }
: null
const items = body?.data?.mutableRefss?.items ?? []
if (items.some((item) => documentHasSlug(item.value, slug, present))) return
await page.waitForTimeout(1_000)
}
// Indexer GraphQL may lag or be down; the reconnect assertions still check the outcome.
await page.waitForTimeout(15_000)
}

test.describe('Cause bookmarks', () => {
test.beforeEach(async ({ page }) => {
await page.goto(appPath('/'))
await clearBrowserStorage(page)
await page.goto(appPath('/'))
})

test('keeps and removes a published cause, and hydrates after reconnect', async ({ page }) => {
test.setTimeout(240_000)
await connectHardhat(page, 0)
await startCause(page)

await page.getByTestId('cause-add-plank').click()
await page.getByTestId('plank-text-0').fill(
'Every Oak Street block has working streetlights by June.',
)
await page.getByTestId('plank-publish-0').click()
await expect(page.getByTestId('plank-row-published')).toHaveCount(1, { timeout: 60_000 })

const title = `Bookmarked Oak Street ${Date.now()}`
const slug = `bookmarked-oak-${Date.now()}`
await page.getByTestId('roster-title').fill(title)
await page.getByTestId('roster-summary').fill('Neighbors organizing for working streetlights.')
await page.getByTestId('roster-slug').fill(slug)
await page.getByTestId('roster-publish-anyway').click()
await expect(page).toHaveURL(new RegExp(`/cause/0x[0-9a-f]{40}/${slug}$`), {
timeout: 60_000,
})
const stableUrl = page.url()

await clearBrowserStorage(page)
await page.goto(stableUrl)
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({
timeout: 30_000,
})
await connectHardhat(page, 1)

await page.getByTestId('cause-keep-on-device').click()
await expect(page.getByTestId('cause-remove-from-device')).toBeVisible({ timeout: 10_000 })
await waitForWalletBookmarkWrite(page, slug, true)

await page.getByTestId('nav-causes').click()
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({
timeout: 30_000,
})

await clearBrowserStorage(page)
await page.goto(appPath('/causes'))
await connectHardhat(page, 1)
await expect(page.getByRole('heading', { name: title, exact: true })).toBeVisible({
timeout: 45_000,
})

await page.getByRole('heading', { name: title, exact: true }).click()
await expect(page.getByTestId('cause-remove-from-device')).toBeVisible({ timeout: 30_000 })
await page.getByTestId('cause-remove-from-device').click()
await expect(page.getByTestId('cause-keep-on-device')).toBeVisible()
await waitForWalletBookmarkWrite(page, slug, false)

await page.getByTestId('nav-causes').click()
await expect(page.getByRole('heading', { name: title, exact: true })).toHaveCount(0)

await clearBrowserStorage(page)
await page.goto(appPath('/causes'))
await connectHardhat(page, 1)
await expect(page.getByRole('heading', { name: 'Causes', exact: true })).toBeVisible()
await expect(page.getByRole('heading', { name: title, exact: true })).toHaveCount(0, {
timeout: 45_000,
})
})
})
123 changes: 122 additions & 1 deletion causestarter/src/lib/causeBookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import {
mergeBookmarkDocuments,
mergeBookmarkIds,
parseCauseBookmarkDocument,
parseCauseBookmarkList,
rememberBookmarkKept,
rememberBookmarkRemoved,
sameBookmarkDocument,
sameBookmarkList,
serializeCauseBookmarkDocument,
serializeCauseBookmarkList,
} from './causeBookmarks'

describe('causeBookmarks', () => {
afterEach(() => {
localStorage.clear()
})

it('round-trips published cause identities and ignores statement-shaped lists', () => {
const ids = [
{ owner: '0xAbC0000000000000000000000000000000000001', slug: 'safer-nights' },
Expand All @@ -16,6 +26,8 @@ describe('causeBookmarks', () => {
const encoded = serializeCauseBookmarkList(ids)
expect(encoded).not.toContain('bafy')
expect(JSON.parse(encoded).causes).toHaveLength(2)
expect(JSON.parse(encoded).removed).toEqual([])
expect(JSON.parse(encoded).version).toBe(2)

const parsed = parseCauseBookmarkList(encoded)
expect(parsed).toEqual([
Expand All @@ -27,6 +39,18 @@ describe('causeBookmarks', () => {
expect(parseCauseBookmarkList(null)).toBeNull()
})

it('reads version-1 wallet documents as empty tombstone lists', () => {
const v1 = JSON.stringify({
version: 1,
causes: [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }],
})
expect(parseCauseBookmarkDocument(v1)).toEqual({
version: 1,
causes: [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }],
removed: [],
})
})

it('unions lists without mixing keys', () => {
const a = [{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one' }]
const b = [{ owner: '0xABC0000000000000000000000000000000000001', slug: 'one' }, { owner: '0x0000000000000000000000000000000000000002', slug: 'two' }]
Expand All @@ -36,5 +60,102 @@ describe('causeBookmarks', () => {
])
expect(sameBookmarkList(a, b)).toBe(false)
expect(sameBookmarkList(mergeBookmarkIds(a, b), b)).toBe(true)
expect(sameBookmarkList(
[{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one', updatedAt: '2026-01-01T00:00:00.000Z' }],
[{ owner: '0xabc0000000000000000000000000000000000001', slug: 'one', updatedAt: '2026-02-01T00:00:00.000Z' }],
)).toBe(false)
})

it('lets a later tombstone beat a stale keep, and a later keep restore it', () => {
const owner = '0xabc0000000000000000000000000000000000001'
const earlier = mergeBookmarkDocuments(
{
version: 1,
causes: [{ owner, slug: 'safer-nights', updatedAt: '2026-01-01T00:00:00.000Z' }],
removed: [],
},
{
version: 2,
causes: [],
removed: [{ owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' }],
},
)
expect(earlier.causes).toEqual([])
expect(earlier.removed).toEqual([
{ owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' },
])

const restored = mergeBookmarkDocuments(earlier, {
version: 2,
causes: [{ owner, slug: 'safer-nights', updatedAt: '2026-03-01T00:00:00.000Z' }],
removed: [],
})
expect(restored.causes).toEqual([
{ owner, slug: 'safer-nights', updatedAt: '2026-03-01T00:00:00.000Z' },
])
expect(restored.removed).toEqual([])
expect(sameBookmarkDocument(earlier, restored)).toBe(false)
})

it('prefers a tombstone when keep and remove share a stamp', () => {
const owner = '0xabc0000000000000000000000000000000000001'
const at = '2026-04-01T00:00:00.000Z'
const merged = mergeBookmarkDocuments(
{ version: 2, causes: [{ owner, slug: 'safer-nights', updatedAt: at }], removed: [] },
{ version: 2, causes: [], removed: [{ owner, slug: 'safer-nights', updatedAt: at }] },
)
expect(merged.causes).toEqual([])
expect(merged.removed[0]?.slug).toBe('safer-nights')
})

it('tracks local tombstones across keep and remove', () => {
const id = { owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }
rememberBookmarkRemoved(id, '2026-05-01T00:00:00.000Z')
expect(parseCauseBookmarkDocument(serializeCauseBookmarkDocument({
version: 2,
causes: [],
removed: [{ ...id, updatedAt: '2026-05-01T00:00:00.000Z' }],
}))?.removed).toHaveLength(1)
rememberBookmarkKept(id)
const encoded = serializeCauseBookmarkList([id])
expect(JSON.parse(encoded).removed).toEqual([])
})

it('does not let a later cause-draft clock beat a tombstone', () => {
const owner = '0xabc0000000000000000000000000000000000001'
const merged = mergeBookmarkDocuments(
{
version: 2,
causes: [],
removed: [{ owner, slug: 'safer-nights', updatedAt: '2026-02-01T00:00:00.000Z' }],
},
{
version: 2,
causes: [{ owner, slug: 'safer-nights' }],
removed: [],
},
)
expect(merged.causes).toEqual([])
expect(merged.removed[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z')
})

it('lets an explicit later keep restore a tombstoned identity', () => {
const id = { owner: '0xabc0000000000000000000000000000000000001', slug: 'safer-nights' }
rememberBookmarkRemoved(id, '2026-02-01T00:00:00.000Z')
rememberBookmarkKept(id, '2026-03-01T00:00:00.000Z')
const merged = mergeBookmarkDocuments(
{
version: 2,
causes: [],
removed: [{ ...id, updatedAt: '2026-02-01T00:00:00.000Z' }],
},
{
version: 2,
causes: [{ ...id, updatedAt: '2026-03-01T00:00:00.000Z' }],
removed: [],
},
)
expect(merged.causes[0]?.updatedAt).toBe('2026-03-01T00:00:00.000Z')
expect(merged.removed).toEqual([])
})
})
Loading
Loading