Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/copy-error-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@viamrobotics/test-widgets': patch
---

Add copy button to error display and prevent re-rendering unchanged errors during query polling
77 changes: 77 additions & 0 deletions src/lib/components/__tests__/error.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { render, screen, waitFor } from '@testing-library/svelte'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import Subject from '../error.svelte'

describe('<ErrorDisplay>', () => {
let user: ReturnType<typeof userEvent.setup>

beforeEach(() => {
user = userEvent.setup()
})

it('renders error name and message', () => {
const error = new Error('Something went wrong')
error.name = 'TestError'

render(Subject, { lastError: error })
expect(screen.getByText('TestError: Something went wrong')).toBeInTheDocument()
})

it('does not render when lastError is null', () => {
const { container } = render(Subject, { lastError: null })
expect(container.querySelector('p')).not.toBeInTheDocument()
})

it('does not render when lastError is undefined', () => {
const { container } = render(Subject, { lastError: undefined })
expect(container.querySelector('p')).not.toBeInTheDocument()
})

it('renders a copy button', () => {
const error = new Error('Something went wrong')
render(Subject, { lastError: error })
expect(screen.getByRole('button', { name: /copy error/iu })).toBeInTheDocument()
})

it('copies error text to clipboard when copy button is clicked', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })

const error = new Error('Something went wrong')
error.name = 'TestError'
render(Subject, { lastError: error })

const copyButton = screen.getByRole('button', { name: /copy error/iu })
await user.click(copyButton)

expect(writeText).toHaveBeenCalledWith('TestError: Something went wrong')
})

it('shows check icon after successful copy and restores content-copy icon after timeout', async () => {
vi.useFakeTimers()
const timerUser = userEvent.setup({ advanceTimers: vi.advanceTimersByTime.bind(vi) })
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })

const error = new Error('Something went wrong')
error.name = 'TestError'
render(Subject, { lastError: error })

expect(screen.getByTestId('icon-content-copy')).toBeInTheDocument()

const copyButton = screen.getByRole('button', { name: /copy error/iu })
await timerUser.click(copyButton)

await waitFor(() => expect(screen.getByTestId('icon-check')).toBeInTheDocument())
expect(screen.queryByTestId('icon-content-copy')).not.toBeInTheDocument()

vi.runAllTimers()

await waitFor(() => expect(screen.getByTestId('icon-content-copy')).toBeInTheDocument())
expect(screen.queryByTestId('icon-check')).not.toBeInTheDocument()

vi.useRealTimers()
})
})
Comment thread
DTCurrie marked this conversation as resolved.
98 changes: 98 additions & 0 deletions src/lib/components/__tests__/queries.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { QueryObserverResult } from '@tanstack/svelte-query'

import { render, screen } from '@testing-library/svelte'
import { describe, expect, it } from 'vitest'

import Subject from '../queries.svelte'

const createErrorQuery = (name: string, message: string): QueryObserverResult => {
const error = new Error(message)
error.name = name
return {
data: null,
error,
isError: true,
isLoading: false,
isPending: false,
isSuccess: false,
status: 'error',
} as unknown as QueryObserverResult
}

const createSuccessQuery = (data: unknown = null): QueryObserverResult =>
({
data,
error: null,
isError: false,
isLoading: false,
isPending: false,
isSuccess: true,
status: 'success',
}) as unknown as QueryObserverResult

const createLoadingQuery = (): QueryObserverResult =>
({
data: undefined,
error: null,
isError: false,
isLoading: true,
isPending: true,
isSuccess: false,
status: 'pending',
}) as unknown as QueryObserverResult

describe('<Queries> serializeErrors guard', () => {
it('guard passes: updates displayed errors when error content changes', async () => {
const { rerender } = render(Subject, { queries: [createErrorQuery('ErrorA', 'First error')] })
expect(screen.getByText('ErrorA: First error')).toBeInTheDocument()

await rerender({ queries: [createErrorQuery('ErrorB', 'Second error')] })

expect(screen.queryByText('ErrorA: First error')).not.toBeInTheDocument()
expect(screen.getByText('ErrorB: Second error')).toBeInTheDocument()
})

it('guard blocks: preserves DOM element when same error content is re-provided', async () => {
const { rerender } = render(Subject, {
queries: [createErrorQuery('TestError', 'Same message')],
})
const element = screen.getByText('TestError: Same message')

// Rerender with a new object that has identical content — guard should block state update
await rerender({ queries: [createErrorQuery('TestError', 'Same message')] })

expect(screen.getByText('TestError: Same message')).toBe(element)
})

it('guard resets: clears errors when all queries succeed', async () => {
const { rerender } = render(Subject, { queries: [createErrorQuery('TestError', 'An error')] })
expect(screen.getByText('TestError: An error')).toBeInTheDocument()

await rerender({ queries: [createSuccessQuery()] })

expect(screen.queryByText('TestError: An error')).not.toBeInTheDocument()
})

it('guard preserves errors during loading (errors are null while polling)', async () => {
const { rerender } = render(Subject, { queries: [createErrorQuery('TestError', 'An error')] })
expect(screen.getByText('TestError: An error')).toBeInTheDocument()

// During polling queries may briefly be in loading state with null errors
await rerender({ queries: [createLoadingQuery()] })

expect(screen.getByText('TestError: An error')).toBeInTheDocument()
})

it('guard blocks: new error object with same content as existing does not update state', async () => {
const { rerender } = render(Subject, {
queries: [createErrorQuery('TestError', 'Duplicate content')],
})
const element = screen.getByText('TestError: Duplicate content')

// A second error object with the same name+message is a duplicate — guard should block
const newQuery = createErrorQuery('TestError', 'Duplicate content')
await rerender({ queries: [newQuery] })

expect(screen.getByText('TestError: Duplicate content')).toBe(element)
})
})
49 changes: 40 additions & 9 deletions src/lib/components/error.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { IconButton, Tooltip } from '@viamrobotics/prime-core'
import { twMerge } from 'tailwind-merge'

interface Props {
Expand All @@ -11,15 +12,45 @@

const { lastError, id = `error_${defaultId}`, class: className = '' }: Props = $props()

const errorName = $derived(lastError?.name)
const errorMessage = $derived(lastError?.message)
const errorText = $derived(
lastError?.name && lastError.message ? `${lastError.name}: ${lastError.message}` : ''
)

let showCopySuccess = $state(false)
let copySuccessTimeoutId: ReturnType<typeof setTimeout> | undefined

const copyErrorToClipboard = async () => {
try {
await globalThis.navigator.clipboard.writeText(errorText)
showCopySuccess = true
clearTimeout(copySuccessTimeoutId)
copySuccessTimeoutId = setTimeout(() => {
showCopySuccess = false
}, 750)
} catch (error) {
console.error('Failed to copy error to clipboard', error)
}
}

$effect(() => () => clearTimeout(copySuccessTimeoutId))
</script>

{#if errorName && errorMessage}
<p
{id}
class={twMerge('font-roboto-mono text-danger-dark text-xs', className)}
>
{`${errorName}: ${errorMessage}`}
</p>
{#if errorText}
<div class="flex items-center justify-between gap-1">
<p
{id}
class={twMerge('font-roboto-mono text-danger-dark text-xs', className)}
>
{errorText}
</p>
<Tooltip let:tooltipID>
<IconButton
aria-describedby={tooltipID}
icon={showCopySuccess ? 'check' : 'content-copy'}
label="Copy error"
on:click={copyErrorToClipboard}
/>
<div slot="description">Copy error</div>
</Tooltip>
</div>
{/if}
10 changes: 8 additions & 2 deletions src/lib/components/queries.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@
let errors = $state.raw<Error[]>([])
let data = $state.raw<unknown[]>([])

const serializeErrors = (errs: Error[]) =>
errs.map((error) => `${error.name}\0${error.message}`).join('\0\0')

// Errors are null during loading, so keep the latest errors during polling.
// Only update errors if the content has changed to avoid re-rendering identical errors.
$effect.pre(() => {
const nextErrors = queries.map((query) => query.error).filter((error) => error !== null)
if (nextErrors.length > 0) {
errors = nextErrors
if (serializeErrors(nextErrors) !== serializeErrors(errors)) {
errors = nextErrors
}
} else if (queries.every((query) => query.isSuccess)) {
errors = []
}
Expand All @@ -55,7 +61,7 @@
{contentRect}
cx={contentCx}
>
{#each errors as error (error)}
{#each errors as error (`${error.name}\0${error.message}`)}
<ErrorDisplay lastError={error} />
{/each}
</ContentRect>
Expand Down
Loading