// @vitest-environment jsdom import { COLLECTIONS, materialize, MemoryRecordStore, type GoalView, type ReviewRecord, type UnitView, } from '@radial/core' import { FIXTURE_DIDS, fixtureSpace } from '@radial/core/fixture' import { flushSync, mount, unmount } from 'svelte' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { buildDirectory } from '$lib/directory.js' import type { Space } from '$lib/space.js' import { unitsOf } from '$lib/space.js' import UnitDetail from './UnitDetail.svelte' // The read half of pictures in a review finding. // // A finding is markdown out of somebody else's repo, so it renders through the same `Prose`/`Runs` // path every other body does — which means a screenshot a reviewer pasted shows up under the // severity that judges it, and a body that says something else entirely still cannot become markup // or a fetch. Mounted over the real fixture fold, so what is on screen came through `materialize()` // and `timeline()` rather than through a shape invented for this test. const resolveImage = vi.hoisted(() => vi.fn()) const mockAccount = vi.hoisted(() => ({ status: 'signed-out', did: '' })) vi.mock('$lib/image-resolve.js', () => ({ resolveImage })) vi.mock('$lib/auth.svelte.js', () => ({ account: mockAccount, })) const IMAGE = 'at://did:plc:reviewer/com.disnetdev.radial.image/3mrvxfdae4l2m' const BLOB = 'https://pds.test/xrpc/com.atproto.sync.getBlob?did=did:plc:reviewer&cid=bafkblob' /** The review the fixture already carries on v1 of the queue plan — three findings, one per severity. */ const REVIEW_RKEY = '3lbq3rev0001' const GOAL_TITLE = 'Review queue in the web UI' /** * The fixture space, with the findings of that one review rewritten. * * Rewritten rather than hand-built: the records go through the same `MemoryRecordStore` and the same * `materialize()` the app runs, so the drawer under test is reading a real fold. */ function spaceWithFindings(bodies: string[]): Space { const fixture = fixtureSpace() const store = new MemoryRecordStore() for (const record of fixture.records) { if (record.collection === COLLECTIONS.review && record.rkey === REVIEW_RKEY) { const value = record.value as ReviewRecord store.put({ ...record, value: { ...value, findings: value.findings.map((finding, at) => ({ ...finding, ...(bodies[at] === undefined ? {} : { body: bodies[at] as string }), })), }, }) continue } store.put(record) } const index = materialize(store, { spaceUri: fixture.spaceUri, asOf: fixture.asOf }) return { uri: fixture.spaceUri, index, directory: buildDirectory(index, {}), asOf: fixture.asOf, fixture: true, } } const goalOf = (space: Space): GoalView => { const found = space.index.goals.find((entry) => entry.target.value.title === GOAL_TITLE) if (!found) throw new Error('fixture goal missing') return found } const planOf = (space: Space, view: GoalView): UnitView => { const found = unitsOf(space.index, view).find((entry) => entry.type === 'plan') if (!found) throw new Error('fixture plan missing') return found } let host: HTMLDivElement let component: Record | undefined /** Open the drawer on v1 — the version the fixture's `request_changes` review pins. */ function open(bodies: string[], live = false): void { const space = { ...spaceWithFindings(bodies), ...(live ? { fixture: false } : {}) } const view = goalOf(space) component = mount(UnitDetail, { target: host, props: { unit: planOf(space, view), view, space, startVersion: 1 }, }) as Record flushSync() } /** The finding block that holds a given severity. */ const findingWith = (severity: string): HTMLElement => { const found = [...host.querySelectorAll('.finding')].find( (entry) => entry.querySelector('.sev')?.textContent?.trim() === severity, ) if (!found) throw new Error(`no ${severity} finding`) return found as HTMLElement } beforeEach(() => { resolveImage.mockReset() mockAccount.status = 'signed-out' mockAccount.did = '' host = document.createElement('div') document.body.append(host) }) afterEach(() => { if (component) unmount(component) component = undefined host.remove() }) describe('a review finding on screen', () => { it('shows a picture the reviewer pasted, under the severity and the place it judges', async () => { resolveImage.mockReturnValue(Promise.resolve({ status: 'ready', url: BLOB, alt: 'from the file' })) open([`The pane bar wraps at 380px:\n\n![the wrapped bar](radial-image:${IMAGE})`]) const error = findingWith('error') await vi.waitFor(() => { flushSync() expect(error.querySelector('img')).not.toBeNull() }) const picture = error.querySelector('img') as HTMLImageElement expect(picture.getAttribute('src')).toBe(BLOB) // The BODY's alt wins over the uploader's: the reviewer said what the picture is doing here. expect(picture.getAttribute('alt')).toBe('the wrapped bar') expect(resolveImage).toHaveBeenCalledWith(`radial-image:${IMAGE}`) // …and the finding is still a finding: severity, path and line are where they were, and the // prose around the picture reads as prose rather than as its own source. expect(error.querySelector('.sev')?.textContent?.trim()).toBe('error') expect(error.querySelector('.loc')?.textContent).toBe('queue/+page.server.ts:41') expect(error.textContent).toContain('The pane bar wraps at 380px:') expect(error.textContent).not.toContain('radial-image:') }) it('renders a finding as markdown, and never as markup', () => { open([ '**Two** things:\n\n- resolve the cid\n- say so in the body\n\n', ]) const error = findingWith('error') expect(error.querySelector('strong')?.textContent).toBe('Two') expect(error.querySelectorAll('li')).toHaveLength(2) // The tag reaches the screen as the author's own source, which is both safe and honest. expect(error.querySelector('img')).toBeNull() expect(error.textContent).toContain(' { open([ '![nope](radial-image:not-an-at-uri)', '![wrong collection](radial-image:at://did:plc:reviewer/com.disnetdev.radial.artifact/plan-1)', '![local](../screenshots/bar.png)', ]) // Nothing was fetched and nothing was resolved: the strict locator parser refused all three, and // a relative path is not somewhere this app sends anybody. expect(resolveImage).not.toHaveBeenCalled() expect(host.querySelector('.finding img')).toBeNull() // Each still says the author meant to show something, by the alt they wrote. for (const [severity, alt] of [ ['error', 'nope'], ['warning', 'wrong collection'], ['info', 'local'], ] as const) { const block = findingWith(severity) expect(block.querySelector('.im')?.textContent).toContain(alt) } }) it('leaves the placeholder standing when the picture cannot be resolved', async () => { resolveImage.mockReturnValue( Promise.resolve({ status: 'missing', reason: 'no image record at that address' }), ) open([`![the wrapped bar](radial-image:${IMAGE})`]) const error = findingWith('error') await vi.waitFor(() => { flushSync() expect(error.querySelector('.im')?.getAttribute('title')).toContain('no image record') }) expect(error.querySelector('img')).toBeNull() expect(error.querySelector('.im')?.textContent).toContain('the wrapped bar') }) }) describe('reviewing selected artifact prose', () => { it('opens beside the selection and carries the quote into a normal review finding', async () => { mockAccount.status = 'signed-in' mockAccount.did = FIXTURE_DIDS.tim open([], true) const prose = host.querySelector('.artifact-prose .body') const walker = prose ? document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, { acceptNode: (node) => node.textContent?.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP, }) : undefined const text = walker?.nextNode() if (!text?.textContent) throw new Error('artifact prose missing') const stop = Math.min(28, text.textContent.length) const quote = text.textContent.slice(0, stop) const range = document.createRange() range.setStart(text, 0) range.setEnd(text, stop) Object.defineProperty(range, 'getBoundingClientRect', { value: () => ({ left: 40, right: 180, top: 20, bottom: 38, width: 140, height: 18 }), }) const selection = window.getSelection() selection?.removeAllRanges() selection?.addRange(range) window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })) flushSync() const popover = host.querySelector('.inline-review') expect(popover?.querySelector('blockquote')?.textContent).toBe(quote) const suggestion = popover?.querySelector('textarea') as HTMLTextAreaElement suggestion.value = 'State the observable outcome.' suggestion.dispatchEvent(new InputEvent('input', { bubbles: true })) flushSync() const add = popover?.querySelector('button.btn-bad') as HTMLButtonElement expect(add.disabled).toBe(false) add.click() flushSync() await vi.waitFor(() => { flushSync() const editor = host.querySelector('.fedit .cm-content, .fedit textarea') const value = editor instanceof HTMLTextAreaElement ? editor.value : editor?.textContent expect(value).toContain(`> ${quote}`) expect(value).toContain('State the observable outcome.') }) expect((host.querySelector('.fedit select') as HTMLSelectElement).value).toBe('warning') }) })