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
25 changes: 25 additions & 0 deletions src/__tests__/collabExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,31 @@ describe('createCollabBinding', () => {
binding.destroy()
})

test('JOINER: empty initialContent is never seeded, even after sync', () => {
// A share-link joiner opens an EMPTY local note (content ''). It must
// receive the room's content over the wire and NEVER push its own empty
// body into the shared doc — otherwise two joiners could blank the room.
const binding = createCollabBinding({
url: 'wss://x',
room: 'shared-room',
initialContent: '',
user: null,
providerFactory: fakeFactory,
})
// Another client's content arrives, then we reach sync.
binding.ytext.insert(0, 'real shared content')
FakeProvider.last!.fireSync(true)
expect(binding.ytext.toString()).toBe('real shared content')
// Even a sync against a still-empty room leaves it empty (nothing to seed).
const empty = createCollabBinding({
url: 'wss://x', room: 'fresh', initialContent: '', user: null, providerFactory: fakeFactory,
})
FakeProvider.last!.fireSync(true)
expect(empty.ytext.toString()).toBe('')
binding.destroy()
empty.destroy()
})

test('SEED-ON-EMPTY: does nothing on a not-yet-synced event', () => {
const binding = createCollabBinding({
url: 'wss://x',
Expand Down
193 changes: 193 additions & 0 deletions src/__tests__/collabIdFrontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* @jest-environment node
*
* collabIdFrontmatter.test.ts
*
* Feature B (vault-synced collabId) sync-safety guards:
*
* 1. serializeNote emits a `collabId:` frontmatter block ONLY for notes that
* carry one, and parseNote reads it back — a lossless round-trip with and
* without tags also present.
* 2. A note that differs ONLY by gaining a collabId classifies as a clean
* `remoteUpdated`, never a `conflict` — and after apply a re-pull settles
* to `unchanged` (no churn).
* 3. Remote collabId convergence: when local + remote hold DIFFERENT room ids
* but identical bodies, the repo's id wins without a content conflict.
*
* Strategy mirrors githubSyncRoundtrip.test.ts: node env (real crypto.subtle),
* REAL gitBlobSha / serializeNote, only the github.ts network surface mocked.
*/

jest.mock('idb-keyval', () => ({
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(undefined),
del: jest.fn().mockResolvedValue(undefined),
keys: jest.fn().mockResolvedValue([]),
}))

jest.mock('../utils/attachments', () => ({
isAttachmentPath: () => false,
listAttachmentPaths: async () => [],
getAttachmentBlob: async () => null,
getAttachmentGitSha: async () => null,
getAttachmentTombstones: async () => [],
clearAttachmentTombstones: async () => undefined,
putAttachmentAtPath: async () => undefined,
}))

const mockGetBranchRefSha = jest.fn()
const mockGetCommitTreeSha = jest.fn()
const mockGetTreeMap = jest.fn()
const mockGetBlobContent = jest.fn()

jest.mock('../utils/github', () => {
const actual = jest.requireActual('../utils/github')
return {
...actual,
getBranchRefSha: (...a: unknown[]) => mockGetBranchRefSha(...a),
getCommitTreeSha: (...a: unknown[]) => mockGetCommitTreeSha(...a),
getTreeMap: (...a: unknown[]) => mockGetTreeMap(...a),
getBlobContent: (...a: unknown[]) => mockGetBlobContent(...a),
// gitBlobSha stays REAL.
}
})

import { pullFromGitHub, serializeNote, parseNote } from '../utils/githubSync'
import { applyNonConflicts } from '../utils/syncApply'
import { gitBlobSha } from '../utils/github'
import { useNoteStore } from '../stores/noteStore'
import type { Note, SyncRepo } from '@/types'

const REPO: SyncRepo = { owner: 'me', name: 'vault', branch: 'main', isPrivate: false }

beforeEach(async () => {
jest.clearAllMocks()
mockGetBranchRefSha.mockResolvedValue('headsha')
mockGetCommitTreeSha.mockResolvedValue('treesha')
useNoteStore.setState({ notes: [], selectedNoteId: null })
const { useSettingsStore } = await import('../stores/settingsStore')
useSettingsStore.setState({ localGitignoreOverlay: '' })
})

// ── 1. serialize/parse round-trip ───────────────────────────────────────────

describe('serializeNote / parseNote collabId round-trip', () => {
test('a note WITHOUT a collabId serializes body-only (no frontmatter)', () => {
const out = serializeNote({ content: 'Hello world' } as Note)
expect(out).toBe('Hello world\n')
expect(out.startsWith('---')).toBe(false)
})

test('a note WITH a collabId emits a collabId frontmatter block (no blank line)', () => {
const out = serializeNote({ content: 'Hello world', collabId: 'room-1' } as Note)
expect(out).toBe('---\ncollabId: room-1\n---\nHello world\n')
const parsed = parseNote(out)
expect(parsed.collabId).toBe('room-1')
expect(parsed.body).toBe('Hello world\n')
expect(parsed.tags).toEqual([])
})

test('round-trip is lossless: re-serializing the parsed body reproduces identical bytes', () => {
const original = serializeNote({ content: 'A\nB\nC', collabId: 'abc-123' } as Note)
const parsed = parseNote(original)
const reserialized = serializeNote({ content: parsed.body, collabId: parsed.collabId } as Note)
expect(reserialized).toBe(original)
})

test('collabId coexists with a tags frontmatter line (Obsidian-authored file)', () => {
const raw = '---\ncollabId: room-7\ntags: [alpha, "beta gamma"]\n---\nBody text\n'
const parsed = parseNote(raw)
expect(parsed.collabId).toBe('room-7')
expect(parsed.tags).toEqual(['alpha', 'beta gamma'])
expect(parsed.body).toBe('Body text\n')
})

test('an empty-body collab note round-trips', () => {
const out = serializeNote({ content: '', collabId: 'room-9' } as Note)
expect(out).toBe('---\ncollabId: room-9\n---\n')
expect(parseNote(out).collabId).toBe('room-9')
expect(parseNote(out).body).toBe('')
})
})

// ── 2. gaining a collabId is a clean update, not a conflict ──────────────────

test('a note that differs ONLY by gaining a collabId classifies as remoteUpdated (not conflict) and re-pulls unchanged', async () => {
// Seed a synced body-only note (no collabId).
const rawOriginal = 'Hello\n'
const originalSha = await gitBlobSha(rawOriginal)
mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]]))
mockGetBlobContent.mockResolvedValue(rawOriginal)
const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] })
await applyNonConflicts(first.classifications)
const noteId = useNoteStore.getState().notes[0].id
expect(useNoteStore.getState().notes[0].collabId).toBeUndefined()

// Remote gains a collabId frontmatter (a collaborator shared + pushed it).
const rawWithCollab = '---\ncollabId: shared-room\n---\nHello\n'
const collabSha = await gitBlobSha(rawWithCollab)
mockGetTreeMap.mockResolvedValue(new Map([['Note.md', collabSha]]))
mockGetBlobContent.mockImplementation(async (_t, _o, _n, sha: string) =>
sha === collabSha ? rawWithCollab : rawOriginal,
)

const second = await pullFromGitHub({
token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [],
})
expect(second.classifications).toHaveLength(1)
expect(second.classifications[0]).toMatchObject({
kind: 'remoteUpdated', noteId, collabId: 'shared-room',
})

// Apply → the note adopts the room id, body stays clean (no frontmatter leak).
await applyNonConflicts(second.classifications)
const after = useNoteStore.getState().notes[0]
expect(after.collabId).toBe('shared-room')
expect(after.content).toBe('Hello\n')

// Third pull: nothing changed → unchanged (round-trip lossless, no churn).
mockGetTreeMap.mockResolvedValue(new Map([['Note.md', collabSha]]))
mockGetBlobContent.mockResolvedValue(rawWithCollab)
const third = await pullFromGitHub({
token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [],
})
expect(third.classifications).toEqual([{ kind: 'unchanged', noteId }])
})

// ── 3. remote collabId convergence (different ids, same body) ────────────────

test('when local and remote hold DIFFERENT collabIds but identical bodies, the remote id wins without a conflict', async () => {
// Seed a synced body-only note, then mint a LOCAL collabId that was never
// pushed (so localChanged will be true on the next pull).
const rawOriginal = 'Same body\n'
const originalSha = await gitBlobSha(rawOriginal)
mockGetTreeMap.mockResolvedValue(new Map([['Note.md', originalSha]]))
mockGetBlobContent.mockResolvedValue(rawOriginal)
const first = await pullFromGitHub({ token: 't', repo: REPO, notes: [], folders: [] })
await applyNonConflicts(first.classifications)
const noteId = useNoteStore.getState().notes[0].id
const localRoom = useNoteStore.getState().ensureCollabId(noteId)
expect(localRoom).toBeTruthy()

// Remote independently gained a DIFFERENT collabId (same body).
const rawRemote = '---\ncollabId: remote-room\n---\nSame body\n'
const remoteSha = await gitBlobSha(rawRemote)
mockGetTreeMap.mockResolvedValue(new Map([['Note.md', remoteSha]]))
mockGetBlobContent.mockImplementation(async (_t, _o, _n, sha: string) =>
sha === remoteSha ? rawRemote : rawOriginal,
)

const second = await pullFromGitHub({
token: 't', repo: REPO, notes: useNoteStore.getState().notes, folders: [],
})
// Bodies match, only the metadata differs → clean remoteUpdated, NOT conflict.
expect(second.classifications).toHaveLength(1)
expect(second.classifications[0]).toMatchObject({
kind: 'remoteUpdated', noteId, collabId: 'remote-room',
})

await applyNonConflicts(second.classifications)
// Repo's id wins so collaborators converge.
expect(useNoteStore.getState().notes[0].collabId).toBe('remote-room')
expect(useNoteStore.getState().notes[0].content).toBe('Same body\n')
})
131 changes: 131 additions & 0 deletions src/__tests__/collabShare.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @jest-environment jsdom
*
* Feature A (share-session link) unit tests:
* - buildCollabShareLink produces the right origin + collabId + encoded title.
* - parseCollabParam reads `?collab` / `&title` back, tolerating absence.
* - The join decision (create-or-open) seeds a joiner's note EMPTY so the
* collab binding never seeds local content over the wire.
*/

const idb = new Map<string, unknown>()
jest.mock('idb-keyval', () => ({
get: jest.fn((key: string) => Promise.resolve(idb.get(key))),
set: jest.fn((key: string, value: unknown) => { idb.set(key, value); return Promise.resolve() }),
del: jest.fn((key: string) => { idb.delete(key); return Promise.resolve() }),
keys: jest.fn(() => Promise.resolve([...idb.keys()])),
}))

import { buildCollabShareLink, parseCollabParam } from '../utils/collabShare'
import { useNoteStore } from '../stores/noteStore'

beforeEach(() => {
idb.clear()
useNoteStore.setState({ notes: [], selectedNoteId: null })
})

describe('buildCollabShareLink', () => {
test('encodes origin + collabId + url-encoded title', () => {
const link = buildCollabShareLink('https://noteser.app', 'room-abc', 'My Note: Draft')
const url = new URL(link)
expect(url.origin).toBe('https://noteser.app')
expect(url.pathname).toBe('/')
expect(url.searchParams.get('collab')).toBe('room-abc')
expect(url.searchParams.get('title')).toBe('My Note: Draft')
})

test('omits the title param entirely when blank', () => {
expect(buildCollabShareLink('https://noteser.app', 'room-abc')).toBe(
'https://noteser.app/?collab=room-abc',
)
expect(buildCollabShareLink('https://noteser.app', 'room-abc', ' ')).toBe(
'https://noteser.app/?collab=room-abc',
)
})

test('tolerates a trailing slash on the origin', () => {
expect(buildCollabShareLink('https://noteser.app/', 'r1')).toBe(
'https://noteser.app/?collab=r1',
)
})

test('collab comes before title in the query string', () => {
const link = buildCollabShareLink('https://x.dev', 'r1', 'Hello')
expect(link).toBe('https://x.dev/?collab=r1&title=Hello')
})
})

describe('parseCollabParam', () => {
test('parses collab + title', () => {
expect(parseCollabParam('?collab=room-1&title=Hello%20World')).toEqual({
collabId: 'room-1', title: 'Hello World',
})
})

test('parses collab with no title', () => {
expect(parseCollabParam('?collab=room-1')).toEqual({ collabId: 'room-1', title: null })
})

test('blank title becomes null', () => {
expect(parseCollabParam('?collab=room-1&title=')).toEqual({ collabId: 'room-1', title: null })
})

test('returns null when no collab param is present', () => {
expect(parseCollabParam('')).toBeNull()
expect(parseCollabParam('?import=xyz')).toBeNull()
})

test('round-trips a built link', () => {
const link = buildCollabShareLink('https://noteser.app', 'room-xyz', 'A Title')
const search = new URL(link).search
expect(parseCollabParam(search)).toEqual({ collabId: 'room-xyz', title: 'A Title' })
})
})

// The create-or-open decision the page join effect performs. Modelled directly
// against the real note store so we pin the contract the effect relies on.
describe('join-collab create-or-open decision', () => {
function join(search: string): string | null {
const parsed = parseCollabParam(search)
if (!parsed) return null
const existing = useNoteStore.getState().notes.find(
n => n.collabId === parsed.collabId && !n.isDeleted,
)
if (existing) return existing.id
return useNoteStore.getState().addNote({
title: parsed.title || 'Shared note',
folderId: null,
content: '',
collabId: parsed.collabId,
}).id
}

test('creates an EMPTY note seeded with the collabId + title (joiner does NOT seed content)', () => {
const id = join('?collab=room-join&title=Team%20Doc')
const note = useNoteStore.getState().notes.find(n => n.id === id)
expect(note).toBeDefined()
expect(note!.collabId).toBe('room-join')
expect(note!.title).toBe('Team Doc')
// EMPTY body: the joiner receives the room's content over the CRDT wire, so
// createCollabBinding's seed-on-empty (which only fires for non-empty local
// content) must never run on their side.
expect(note!.content).toBe('')
})

test('opens the existing note when one already carries that collabId (no duplicate)', () => {
const created = useNoteStore.getState().addNote({
title: 'Existing', content: 'keep me', collabId: 'room-dup',
})
const id = join('?collab=room-dup')
expect(id).toBe(created.id)
// No second note was created.
expect(useNoteStore.getState().notes.filter(n => n.collabId === 'room-dup')).toHaveLength(1)
// Its content is untouched.
expect(useNoteStore.getState().notes.find(n => n.id === id)!.content).toBe('keep me')
})

test('falls back to a default title when the link carries none', () => {
const id = join('?collab=room-untitled')
expect(useNoteStore.getState().notes.find(n => n.id === id)!.title).toBe('Shared note')
})
})
32 changes: 32 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,38 @@ export default function Home() {
})()
}, [hydrated])

// Join-collab-session (Feature A): when the URL has `?collab=<id>` open (or
// create) the local note bound to that room. If a note with this collabId
// already exists we just open it; otherwise we materialise an EMPTY local
// note seeded with the collabId (+ the title from the link, if any) and let
// the live-collab binding pull the room's current content over the wire — we
// deliberately do NOT seed any local body for a joiner, so the seed-on-empty
// logic in collabExtension never fires on their side. The params are stripped
// afterwards so a refresh does not re-trigger the join.
useEffect(() => {
if (!hydrated) return
if (typeof window === 'undefined') return
const open = async () => {
const { parseCollabParam } = await import('@/utils/collabShare')
const parsed = parseCollabParam(window.location.search)
if (!parsed) return
const existing = useNoteStore.getState().notes.find(
n => n.collabId === parsed.collabId && !n.isDeleted,
)
const noteId = existing
? existing.id
: useNoteStore.getState().addNote({
title: parsed.title || 'Shared note',
folderId: null,
content: '',
collabId: parsed.collabId,
}).id
useWorkspaceStore.getState().openNote(noteId, { preview: false })
window.history.replaceState({}, '', window.location.pathname)
}
void open()
}, [hydrated])

// Migrate old data on first load. Async-yielding migration so a
// legacy vault with hundreds of notes does not block first paint on
// iOS (the watchdog kills any task held longer than its window).
Expand Down
Loading
Loading