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
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
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: [] })
|
||
})
|
||
})
|