snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
255
packages/tlschema/src/records/TLComment.test.ts
Normal file
255
packages/tlschema/src/records/TLComment.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTLSchema } from '../createTLSchema'
|
||||
import { toRichText } from '../misc/TLRichText'
|
||||
import {
|
||||
commentReactionRecordConfig,
|
||||
commentRecordConfig,
|
||||
commentSchemaRecords,
|
||||
commentThreadRecordConfig,
|
||||
createComment,
|
||||
createCommentId,
|
||||
createCommentReaction,
|
||||
createCommentReactionId,
|
||||
createCommentThread,
|
||||
createCommentThreadId,
|
||||
isCommentId,
|
||||
isCommentReactionId,
|
||||
isCommentThreadId,
|
||||
TLCommentAnchor,
|
||||
} from './TLComment'
|
||||
import { TLPageId } from './TLPage'
|
||||
import { TLShapeId } from './TLShape'
|
||||
|
||||
const pageId = 'page:page1' as TLPageId
|
||||
const shapeId = 'shape:box1' as TLShapeId
|
||||
|
||||
const anchors: TLCommentAnchor[] = [
|
||||
{ type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
||||
{ type: 'point', x: 100, y: 200 },
|
||||
{ type: 'region', x: 0, y: 0, w: 300, h: 150 },
|
||||
{ type: 'page' },
|
||||
]
|
||||
|
||||
describe('TLCommentThread', () => {
|
||||
it.each(anchors)('creates a valid thread anchored to %j', (anchor) => {
|
||||
const thread = createCommentThread({ pageId, anchor, createdBy: 'user1', now: 1000 })
|
||||
expect(commentThreadRecordConfig.validator.validate(thread)).toEqual(thread)
|
||||
expect(thread).toMatchObject({
|
||||
typeName: 'comment-thread',
|
||||
pageId,
|
||||
anchor,
|
||||
createdAt: 1000,
|
||||
resolved: null,
|
||||
meta: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('validates resolution state', () => {
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: { type: 'page' },
|
||||
createdBy: 'user1',
|
||||
now: 1000,
|
||||
})
|
||||
const resolved = { ...thread, resolved: { at: 2000, by: 'user2' } }
|
||||
expect(commentThreadRecordConfig.validator.validate(resolved)).toEqual(resolved)
|
||||
expect(() =>
|
||||
commentThreadRecordConfig.validator.validate({ ...thread, resolved: { at: 2000 } })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('rejects unknown anchor kinds and malformed anchors', () => {
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
||||
createdBy: 'user1',
|
||||
now: 1000,
|
||||
})
|
||||
expect(() =>
|
||||
commentThreadRecordConfig.validator.validate({
|
||||
...thread,
|
||||
anchor: { type: 'volume', level: 11 },
|
||||
})
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
commentThreadRecordConfig.validator.validate({
|
||||
...thread,
|
||||
// shape anchors must reference a shape id
|
||||
anchor: { type: 'shape', shapeId: 'page:nope', x: 1, y: 0, isPrecise: false },
|
||||
})
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TLComment', () => {
|
||||
const threadId = createCommentThreadId('thread1')
|
||||
|
||||
it('creates a valid comment with a rich text body', () => {
|
||||
const comment = createComment({
|
||||
threadId,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText('hello'),
|
||||
now: 1000,
|
||||
})
|
||||
expect(commentRecordConfig.validator.validate(comment)).toEqual(comment)
|
||||
expect(comment).toMatchObject({
|
||||
typeName: 'comment',
|
||||
threadId,
|
||||
pageId,
|
||||
createdAt: 1000,
|
||||
editedAt: null,
|
||||
isDeleted: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates soft-deletion state', () => {
|
||||
const comment = createComment({
|
||||
threadId,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText('hello'),
|
||||
now: 1000,
|
||||
})
|
||||
const deleted = { ...comment, isDeleted: true }
|
||||
expect(commentRecordConfig.validator.validate(deleted)).toEqual(deleted)
|
||||
expect(() => commentRecordConfig.validator.validate({ ...comment, isDeleted: 'yes' })).toThrow()
|
||||
})
|
||||
|
||||
it('accepts an editedAt timestamp after edits', () => {
|
||||
const comment = createComment({
|
||||
threadId,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText('hello'),
|
||||
now: 1000,
|
||||
})
|
||||
const edited = { ...comment, editedAt: 2000, body: toRichText('hello, edited') }
|
||||
expect(commentRecordConfig.validator.validate(edited)).toEqual(edited)
|
||||
})
|
||||
|
||||
it('rejects a threadId that is not a comment-thread id', () => {
|
||||
const comment = createComment({
|
||||
threadId,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText('hello'),
|
||||
now: 1000,
|
||||
})
|
||||
expect(() =>
|
||||
commentRecordConfig.validator.validate({ ...comment, threadId: 'comment:not-a-thread' })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('schema registration', () => {
|
||||
it('registers both record types via commentSchemaRecords', () => {
|
||||
const schema = createTLSchema({ records: commentSchemaRecords })
|
||||
// custom record types aren't part of the default TLRecord union, so index untyped
|
||||
const types = schema.types as Record<string, { scope: string } | undefined>
|
||||
expect(types['comment-thread']?.scope).toBe('document')
|
||||
expect(types['comment']?.scope).toBe('document')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommentThreadId / isCommentId', () => {
|
||||
it('isCommentThreadId accepts comment-thread ids and rejects everything else', () => {
|
||||
expect(isCommentThreadId(createCommentThreadId())).toBe(true)
|
||||
expect(isCommentThreadId(createCommentId())).toBe(false)
|
||||
expect(isCommentThreadId('shape:box1')).toBe(false)
|
||||
expect(isCommentThreadId('not-an-id')).toBe(false)
|
||||
})
|
||||
|
||||
it('isCommentId accepts comment ids and rejects everything else, including comment-thread ids', () => {
|
||||
expect(isCommentId(createCommentId())).toBe(true)
|
||||
expect(isCommentId(createCommentThreadId())).toBe(false)
|
||||
expect(isCommentId('shape:box1')).toBe(false)
|
||||
expect(isCommentId('not-an-id')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment-thread migrations', () => {
|
||||
const migration = (commentThreadRecordConfig.migrations as any).sequence.find(
|
||||
(m: any) => m.id === 'com.tldraw.comment-thread/2'
|
||||
) as { up(r: any): any; down(r: any): any }
|
||||
|
||||
it('stamps existing shape anchors with the top-right position (x:1, y:0)', () => {
|
||||
expect(migration.up({ anchor: { type: 'shape', shapeId } })).toEqual({
|
||||
anchor: { type: 'shape', shapeId, x: 1, y: 0, isPrecise: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves shape anchors that already have x/y unchanged', () => {
|
||||
const anchor = { type: 'shape', shapeId, x: 0.25, y: 0.75, isPrecise: true }
|
||||
expect(migration.up({ anchor })).toEqual({ anchor })
|
||||
})
|
||||
|
||||
it('leaves non-shape anchors untouched', () => {
|
||||
const anchor = { type: 'point', x: 5, y: 6 }
|
||||
expect(migration.up({ anchor })).toEqual({ anchor })
|
||||
})
|
||||
|
||||
it('down-migrates by dropping x/y', () => {
|
||||
expect(
|
||||
migration.down({ anchor: { type: 'shape', shapeId, x: 0.3, y: 0.4, isPrecise: true } })
|
||||
).toEqual({
|
||||
anchor: { type: 'shape', shapeId },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment-reaction record', () => {
|
||||
it('createCommentReaction derives its id from the comment, user, and emoji', () => {
|
||||
const commentId = createCommentId('c1')
|
||||
const make = (emoji: string) =>
|
||||
createCommentReaction({
|
||||
commentId,
|
||||
threadId: createCommentThreadId('t1'),
|
||||
pageId,
|
||||
userId: 'user1',
|
||||
emoji,
|
||||
})
|
||||
// same emoji → same record (re-picking toggles it); different emoji → its own record (multi)
|
||||
expect(make('👍').id).toBe(make('👍').id)
|
||||
expect(make('👍').id).not.toBe(make('🎉').id)
|
||||
expect(isCommentReactionId(make('👍').id)).toBe(true)
|
||||
})
|
||||
|
||||
it('validates a well-formed reaction record', () => {
|
||||
const reaction = createCommentReaction({
|
||||
commentId: createCommentId('c1'),
|
||||
threadId: createCommentThreadId('t1'),
|
||||
pageId,
|
||||
userId: 'user1',
|
||||
emoji: '👍',
|
||||
})
|
||||
expect(commentReactionRecordConfig.validator.validate(reaction)).toEqual(reaction)
|
||||
})
|
||||
|
||||
it('rejects an empty or oversized emoji token', () => {
|
||||
const reaction = createCommentReaction({
|
||||
commentId: createCommentId('c1'),
|
||||
threadId: createCommentThreadId('t1'),
|
||||
pageId,
|
||||
userId: 'user1',
|
||||
emoji: '👍',
|
||||
})
|
||||
// the reaction id embeds the emoji verbatim, so an unbounded token means an unbounded id
|
||||
expect(() =>
|
||||
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: '' })
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
commentReactionRecordConfig.validator.validate({ ...reaction, emoji: 'x'.repeat(65) })
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
it('createCommentReactionId is injective when a part contains a colon', () => {
|
||||
// two different triples that a naive slice+join would collapse onto one id:
|
||||
// comment "foo" + user "bar:baz" + 👍 vs comment "foo:bar" + user "baz" + 👍
|
||||
const a = createCommentReactionId(createCommentId('foo'), 'bar:baz', '👍')
|
||||
const b = createCommentReactionId(createCommentId('foo:bar'), 'baz', '👍')
|
||||
expect(a).not.toBe(b)
|
||||
expect(isCommentReactionId(a)).toBe(true)
|
||||
expect(isCommentReactionId(b)).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user