Files
upstream-tldraw/apps/dotcom/sync-worker/src/routes/tla/boardTools.test.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

332 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import {
BOARD_SEARCH_MAX_TERMS,
BOARD_SEARCH_PAGE_SIZE,
BoardSearchRow,
compareBoardSearchOrder,
getBoardSearchResults,
isAfterBoardSearchCursor,
parseSearchBoardsInput,
} from './boardTools'
function parsedJson(result: ReturnType<typeof getBoardSearchResults>) {
const block = result.content[0]
if (block.type !== 'text') throw new Error('Expected a text block')
return JSON.parse(block.text)
}
function makeRow(overrides: Partial<BoardSearchRow> = {}): BoardSearchRow {
return {
id: 'board-1',
name: 'Roadmap',
// A board the caller made: arrival and creation are the same moment.
arrivedAt: 1_700_000_000_000,
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_500_000,
workspaceName: 'Design',
source: 'owned' as const,
...overrides,
}
}
function makePage(count: number): BoardSearchRow[] {
return Array.from({ length: count }, (_, index) =>
makeRow({
id: `board-${index}`,
arrivedAt: 1_700_000_000_000 - index,
createdAt: 1_700_000_000_000 - index,
})
)
}
describe('parseSearchBoardsInput', () => {
// Listing the newest boards is the tool's other job: it is the only way for a model to answer
// "what boards do I have?" without being handed an id first.
it('treats a missing query as no filter', () => {
expect(parseSearchBoardsInput({})).toEqual({ terms: [], cursor: null })
})
// The wire-legal call a model makes by following the tool's own description literally: this is
// the only tool with `required: []`, so omitted `arguments` is a legitimate "list my newest
// boards", not a malformed call that should hit `requireArgumentsObject(undefined)`.
it('treats wholly omitted arguments the same as an empty object', () => {
expect(parseSearchBoardsInput(undefined)).toEqual(parseSearchBoardsInput({}))
})
it('treats a whitespace-only query as no filter', () => {
expect(parseSearchBoardsInput({ query: ' ' }).terms).toEqual([])
})
// Terms are ANDed, so word order in the query need not match word order in the name.
it('splits a query into terms on whitespace', () => {
expect(parseSearchBoardsInput({ query: 'design system' }).terms).toEqual(['design', 'system'])
})
it('refuses a non-string query', () => {
expect(() => parseSearchBoardsInput({ query: 12 })).toThrow('query must be a string')
})
// A name is a name, not a document: a pasted paragraph is a model mistake, and every term
// becomes its own ILIKE.
it('refuses an over-long query', () => {
expect(() => parseSearchBoardsInput({ query: 'x'.repeat(201) })).toThrow('200 characters')
})
// A query length limit alone does not bound term count, since whitespace is cheap: each term
// becomes its own unindexable ILIKE over an already-unbounded scan.
it('refuses more than the maximum number of terms', () => {
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS + 1 }, (_, i) => `t${i}`).join(' ')
expect(() => parseSearchBoardsInput({ query })).toThrow(
`${BOARD_SEARCH_MAX_TERMS} words or fewer`
)
})
it('accepts exactly the maximum number of terms', () => {
const query = Array.from({ length: BOARD_SEARCH_MAX_TERMS }, (_, i) => `t${i}`).join(' ')
expect(parseSearchBoardsInput({ query }).terms).toHaveLength(BOARD_SEARCH_MAX_TERMS)
})
it('refuses arguments that are not an object', () => {
expect(() => parseSearchBoardsInput('boards')).toThrow('Tool arguments must be an object')
})
// The cursor is opaque so that its scheme can change; a model must only ever hand back one it
// was given.
it('round-trips a cursor from a previous result', () => {
const first = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(parseSearchBoardsInput({ cursor: first.nextCursor }).cursor).toEqual({
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
id: `board-${BOARD_SEARCH_PAGE_SIZE - 1}`,
})
})
// A cursor is a position in one query's results. Carried onto a different query it would point
// at row 20 of a set the model never saw the start of, with nothing in the result to say so.
it('continues a cursor only under the query that minted it', () => {
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['design']))
expect(
parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor }).cursor
).not.toBeNull()
expect(() => parseSearchBoardsInput({ query: 'roadmap', cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
expect(() => parseSearchBoardsInput({ cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
})
// Matching is `ilike` and terms are split on whitespace before they are ever compared, so
// neither case nor spacing changed which boards the cursor was pointing into.
it('treats a requery that differs only in case or spacing as the same query', () => {
const page = parsedJson(
getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), ['Design', 'System'])
)
expect(
parseSearchBoardsInput({ query: ' design system ', cursor: page.nextCursor }).cursor
).not.toBeNull()
})
// The other direction of the same rule: "list my newest boards" is a query too, and a cursor
// into it does not carry over to a search.
it('refuses a no-query cursor once a query is added', () => {
const page = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(() => parseSearchBoardsInput({ query: 'design', cursor: page.nextCursor })).toThrow(
'cursor is from a different query'
)
})
// Fixture board ids in the eval harness are arbitrary strings, and `btoa` throws outside Latin-1.
// That throw would happen inside the route's try block and reach a model as a database failure.
it('round-trips an id that btoa alone could not encode', () => {
// The cursor is minted from the last row *on* the page, so the odd id has to sit there.
const rows = makePage(BOARD_SEARCH_PAGE_SIZE + 1)
rows[BOARD_SEARCH_PAGE_SIZE - 1] = { ...rows[BOARD_SEARCH_PAGE_SIZE - 1], id: 'brädå:1' }
const result = parsedJson(getBoardSearchResults(rows, []))
expect(parseSearchBoardsInput({ cursor: result.nextCursor }).cursor).toEqual({
arrivedAt: 1_700_000_000_000 - (BOARD_SEARCH_PAGE_SIZE - 1),
id: 'brädå:1',
})
})
// Every fixture below carries the trailing `:` that makes it a three-field cursor. Without it the
// field-count check refuses them first, and the guard each case names is never reached — which is
// what happened when the cursor grew its query field and these were not revisited: deleting the
// timestamp validation outright left all of them green.
//
// Nothing downstream can act on a malformed cursor, and silently starting from the first page
// would look to a model like the last page repeating itself.
it('refuses a cursor it did not issue', () => {
expect(() => parseSearchBoardsInput({ cursor: 'not-a-cursor' })).toThrow('cursor is not valid')
expect(() => parseSearchBoardsInput({ cursor: btoa('nonsense') })).toThrow(
'cursor is not valid'
)
expect(() => parseSearchBoardsInput({ cursor: 12 })).toThrow('cursor must be a string')
expect(() => parseSearchBoardsInput({ cursor: btoa('1:%zz:') })).toThrow('cursor is not valid')
})
// `Number.isInteger` admits 1e300, which binds as an out-of-range int8 and makes Postgres throw:
// caller garbage would reach a model as "the board database could not be reached".
it('refuses a sort key no timestamp could be', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('1e300:board-1:') })).toThrow(
'cursor is not valid'
)
expect(() => parseSearchBoardsInput({ cursor: btoa('-1:board-1:') })).toThrow(
'cursor is not valid'
)
})
// `Number('')` is 0, which passes `Number.isSafeInteger(0)`: the forged cursor `btoa(":id:")`
// would otherwise decode to `{createdAt: 0, id: 'id'}` and seek strictly below epoch forever,
// with no signal to the model that its cursor was the problem.
it('refuses a cursor with an empty timestamp half', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa(':board-1:') })).toThrow(
'cursor is not valid'
)
})
// The digit check admits any run of digits, including one past MAX_SAFE_INTEGER. That binds as an
// out-of-range int8 and makes Postgres throw, so caller garbage would reach a model as "the board
// database could not be reached" rather than as a bad cursor. This is the case
// `Number.isSafeInteger` is there for: `1e300` is refused a line earlier, for not being digits.
it('refuses an all-digit timestamp too large to be a safe integer', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('99999999999999999999:board-1:') })).toThrow(
'cursor is not valid'
)
})
// The id half is what makes "where the page ended" a single point rather than a range. Empty, the
// cursor would seek on createdAt alone, and every board sharing that timestamp would be re-served
// or skipped at the boundary.
it('refuses a cursor with an empty id half', () => {
expect(() => parseSearchBoardsInput({ cursor: btoa('1::') })).toThrow('cursor is not valid')
})
// Every other string form `Number()` coerces to a finite value but a plain-digit timestamp
// never is.
it.each(['1e3', ' 5', '+5', 'Infinity'])(
'refuses a timestamp half of %j, which Number() would otherwise accept',
(timestampPart) => {
expect(() => parseSearchBoardsInput({ cursor: btoa(`${timestampPart}:board-1:`) })).toThrow(
'cursor is not valid'
)
}
)
})
// The one statement of result order. The SQL in searchBoards.ts mirrors this, and the eval harness
// pages through fixtures with it — if the two disagree, an eval stops being evidence.
describe('compareBoardSearchOrder', () => {
it('puts the newest-created board first', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 2, id: 'a' }, { arrivedAt: 1, id: 'a' })
).toBeLessThan(0)
})
// Boards created in one batch share a createdAt. Without a unique tiebreaker the cursor cannot
// say where a page ended inside that group.
it('breaks a tie on id, descending', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 0, id: 'b' }, { arrivedAt: 0, id: 'a' })
).toBeLessThan(0)
expect(compareBoardSearchOrder({ arrivedAt: 0, id: 'a' }, { arrivedAt: 0, id: 'a' })).toBe(0)
})
it('sorts an older board after a newer one whatever their ids', () => {
expect(
compareBoardSearchOrder({ arrivedAt: 0, id: 'z' }, { arrivedAt: 1, id: 'a' })
).toBeGreaterThan(0)
})
})
describe('isAfterBoardSearchCursor', () => {
it('accepts only rows that sort after the cursor', () => {
const cursor = { arrivedAt: 10, id: 'm' }
expect(isAfterBoardSearchCursor({ arrivedAt: 9, id: 'z' }, cursor)).toBe(true)
expect(isAfterBoardSearchCursor({ arrivedAt: 11, id: 'a' }, cursor)).toBe(false)
})
// The point of ordering on arrival rather than creation: a board somebody shared with you this
// morning leads the list, however long ago its owner made it. Under a createdAt sort it would land
// below everything you have made since, which for a long-lived board is the bottom of the page.
it('puts a recently shared old board above a board made earlier today', () => {
const sharedThisMorning = { arrivedAt: 1_700_000_000_000, id: 'shared' }
const mineFromLastWeek = { arrivedAt: 1_699_000_000_000, id: 'mine' }
expect(compareBoardSearchOrder(sharedThisMorning, mineFromLastWeek)).toBeLessThan(0)
})
// Descending order means "after" is "less than". Reversed, a page returns itself forever.
it('excludes the cursor row itself and takes the tie on id', () => {
const cursor = { arrivedAt: 10, id: 'm' }
expect(isAfterBoardSearchCursor(cursor, cursor)).toBe(false)
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'l' }, cursor)).toBe(true)
expect(isAfterBoardSearchCursor({ arrivedAt: 10, id: 'n' }, cursor)).toBe(false)
})
})
describe('getBoardSearchResults', () => {
it('shapes a board for the model', () => {
const result = parsedJson(getBoardSearchResults([makeRow()], []))
expect(result).toEqual({
boardCount: 1,
boards: [
{
boardId: 'board-1',
name: 'Roadmap',
// Same instant for a board the caller made; they diverge only for a shared one.
addedAt: '2023-11-14T22:13:20.000Z',
createdAt: '2023-11-14T22:13:20.000Z',
updatedAt: '2023-11-14T22:21:40.000Z',
source: 'owned',
},
],
})
})
// "My workspace" on every row of the common case is noise; on a shared workspace's board it is
// the thing that identifies where the board lives.
it('names the workspace only on boards outside the caller’s own', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'workspace' })], []))
expect(result.boards[0]).toMatchObject({ source: 'workspace', workspaceName: 'Design' })
})
// A link-shared board is owned by a workspace the caller is not in. Reporting its name would hand
// somebody who was given a link the name of an organisation they have no seat in.
it('withholds the workspace name from a link-shared board', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ source: 'shared' })], []))
expect(result.boards[0].source).toBe('shared')
expect(result.boards[0]).not.toHaveProperty('workspaceName')
})
// An unnamed board is titled by its creation date on tldraw.com, in the viewer's locale and
// timezone. A stand-in title invented here would be one the caller never sees on their own
// screen, and one no query could match, since the column holds ''.
it('reports a blank name as blank rather than inventing one', () => {
const result = parsedJson(getBoardSearchResults([makeRow({ name: ' ' })], []))
expect(result.boards[0].name).toBe('')
})
// The query asks for one row more than a page so that "is there another page" needs no second
// count. The extra row must never be shown.
it('serves one page and a cursor from the surplus row', () => {
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE + 1), []))
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
expect(result.boards).toHaveLength(BOARD_SEARCH_PAGE_SIZE)
expect(result.nextCursor).toEqual(expect.any(String))
expect(result.boards.at(-1).boardId).toBe(`board-${BOARD_SEARCH_PAGE_SIZE - 1}`)
})
// A cursor on the last page would have a model fetch an empty page to discover it had finished.
it('omits the cursor on the last page', () => {
const result = parsedJson(getBoardSearchResults(makePage(BOARD_SEARCH_PAGE_SIZE), []))
expect(result.boardCount).toBe(BOARD_SEARCH_PAGE_SIZE)
expect(result.nextCursor).toBeUndefined()
})
// An empty result is a normal result. Models treat isError as failure and retry it.
it('returns an empty result rather than an error', () => {
const result = getBoardSearchResults([], [])
expect(result.isError).toBeUndefined()
expect(parsedJson(result)).toEqual({ boardCount: 0, boards: [] })
})
})