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
256 lines
7.9 KiB
TypeScript
256 lines
7.9 KiB
TypeScript
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)
|
|
})
|
|
})
|