Fix private Linear images in task descriptions (#25849)

* Fix private Linear images in task descriptions

* Strengthen Linear image signing validation and typed access
This commit is contained in:
Neil
2026-10-06 02:49:22 -07:00
committed by GitHub
parent 768c1ea967
commit 5b8a982f8f
13 changed files with 470 additions and 11 deletions
+13
View File
@@ -146,6 +146,19 @@ function mkdtempLike(prefix: string): string {
}
describe('Linear client workspace storage', () => {
it('requests temporary file URLs using only the selected workspace credential', async () => {
const linear = await loadClientModule()
await linear.connect('token-alpha')
await linear.connect('token-beta')
const entry = linear.getClients('org-alpha')[0]
linear.getPublicFileUrlClient(entry)
expect(linearClientMock).toHaveBeenLastCalledWith({
apiKey: 'token-alpha',
headers: {
'public-file-urls-expire-in': String(linear.LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS)
}
})
})
it('stores multiple workspaces and remembers the selected workspace', async () => {
const linear = await loadClientModule()
+10 -7
View File
@@ -3,6 +3,7 @@ import type { LinearClientForWorkspace } from './client'
import { credentialDecryptionMessage } from '../../shared/integration-credential-errors'
const rawRequest = vi.fn()
const signedRawRequest = vi.fn()
const getClients = vi.fn()
const clearToken = vi.fn()
const isAuthError = vi.fn()
@@ -18,6 +19,7 @@ vi.mock('./linear-token-store', () => ({
vi.mock('./client', () => ({
getClients: (...args: unknown[]) => getClients(...args),
getPublicFileUrlClient: () => ({ client: { rawRequest: signedRawRequest } }),
isAuthError: (...args: unknown[]) => isAuthError(...args)
}))
@@ -139,14 +141,14 @@ describe('Linear issue queries', () => {
})
it('fetches issue comments with one request (no per-comment user N+1)', async () => {
rawRequest.mockResolvedValueOnce({
signedRawRequest.mockResolvedValueOnce({
data: {
issue: {
comments: {
nodes: [
{
id: 'comment-1',
body: 'First',
body: '![First](https://uploads.linear.app/w/image?signature=fresh)',
createdAt: '2026-01-02T03:04:05.000Z',
user: { displayName: 'Ada', avatarUrl: 'https://example.com/a.png' }
},
@@ -172,7 +174,7 @@ describe('Linear issue queries', () => {
await expect(getIssueComments('issue-uuid', 'workspace-1')).resolves.toEqual([
{
id: 'comment-1',
body: 'First',
body: '![First](https://uploads.linear.app/w/image?signature=fresh)',
createdAt: '2026-01-02T03:04:05.000Z',
user: { displayName: 'Ada', avatarUrl: 'https://example.com/a.png' }
},
@@ -186,10 +188,11 @@ describe('Linear issue queries', () => {
])
// The point of the fix: one request regardless of comment count.
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueComments')
expect(rawRequest.mock.calls[0][0]).toContain('user {')
expect(rawRequest.mock.calls[0][1]).toEqual({ id: 'issue-uuid' })
expect(signedRawRequest).toHaveBeenCalledTimes(1)
expect(signedRawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueComments')
expect(signedRawRequest.mock.calls[0][0]).toContain('user {')
expect(signedRawRequest.mock.calls[0][1]).toEqual({ id: 'issue-uuid' })
expect(rawRequest).not.toHaveBeenCalled()
})
it('returns an empty comment list when no Linear client is configured', async () => {
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as LinearClientModule from './client'
import { loadLinearSdk } from './linear-sdk'
import { getDescriptionImageUrls } from './linear-description-images'
import { getIssue } from './linear-issue-lookups'
const { rawRequest, issue, getClients, getPublicFileUrlClient, isAuthError } = vi.hoisted(() => ({
rawRequest: vi.fn(),
issue: vi.fn(),
getClients: vi.fn(),
getPublicFileUrlClient: vi.fn(),
isAuthError: vi.fn()
}))
vi.mock('./client', () => ({ getClients, getPublicFileUrlClient, isAuthError }))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./linear-request-concurrency', () => ({ acquire: vi.fn(), release: vi.fn() }))
vi.mock('./linear-issue-query-support', () => ({
mapIssueForWorkspace: async (_entry: unknown, result: unknown) => result
}))
const entry = {
workspace: {
id: 'workspace-1',
organizationId: 'workspace-1',
organizationName: 'Workspace',
displayName: 'Ada',
email: null
},
apiKey: 'private-key',
client: new (loadLinearSdk().LinearClient)({ apiKey: 'private-key' })
}
entry.client.issue = issue
const source = 'https://uploads.linear.app/w/image'
const signed = `${source}?signature=fresh&expires=123`
describe('Linear description images', () => {
afterEach(() => vi.restoreAllMocks())
beforeEach(() => {
vi.resetAllMocks()
getClients.mockReturnValue([entry])
getPublicFileUrlClient.mockReturnValue({ client: { rawRequest } })
})
it('keeps editable description URLs canonical while supplying signed display URLs', async () => {
const description = `Before\n\n![Screenshot](${source})\n\nAfter`
issue.mockResolvedValue({ id: 'issue-1', description })
rawRequest.mockResolvedValue({
data: { issue: { description: description.replace(source, signed) } }
})
await expect(getIssue('issue-1', 'workspace-1')).resolves.toEqual({
id: 'issue-1',
description,
descriptionImageUrls: { [source]: signed }
})
expect(getPublicFileUrlClient).toHaveBeenCalledWith(entry)
expect(rawRequest.mock.calls[0][1]).toEqual({ id: 'issue-1' })
})
it('sends the signing header through the real SDK transport with the owning workspace token', async () => {
const clientModule = await vi.importActual<typeof LinearClientModule>('./client')
getPublicFileUrlClient.mockImplementation(clientModule.getPublicFileUrlClient)
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
Response.json({ data: { issue: { description: `![Screenshot](${signed})` } } })
)
await expect(
getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${source})`)
).resolves.toEqual({ [source]: signed })
expect(fetch).toHaveBeenCalledExactlyOnceWith(
'https://api.linear.app/graphql',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: entry.apiKey,
'public-file-urls-expire-in': String(clientModule.LINEAR_PUBLIC_FILE_URL_EXPIRY_SECONDS)
})
})
)
})
it.each([undefined, 'No images', '![Public](https://example.com/image.png)'])(
'skips the extra read for descriptions without private uploads: %s',
async (description) => {
issue.mockResolvedValue({ id: 'issue-1', description })
await expect(getIssue('issue-1')).resolves.toEqual({ id: 'issue-1', description })
expect(getPublicFileUrlClient).not.toHaveBeenCalled()
}
)
it('matches reordered media by path and preserves the original query parameters', async () => {
const original = `${source}?width=640`
rawRequest.mockResolvedValue({
data: {
issue: {
description: `![Other](https://uploads.linear.app/w/other?signature=other)\n![Screenshot](${signed})`
}
}
})
await expect(
getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${original})`)
).resolves.toEqual({ [original]: signed })
})
it('keeps the issue readable when the signing read fails', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
issue.mockResolvedValue({ id: 'issue-1', description: `![Screenshot](${source})` })
rawRequest.mockRejectedValue(new Error('Network unavailable'))
await expect(getIssue('issue-1')).resolves.toMatchObject({ id: 'issue-1' })
})
it('propagates authentication failures for credential recovery', async () => {
const error = new Error('Unauthorized')
isAuthError.mockReturnValue(true)
rawRequest.mockRejectedValue(error)
await expect(
getDescriptionImageUrls(entry, 'issue-1', `![Screenshot](${source})`)
).rejects.toBe(error)
})
})
@@ -0,0 +1,43 @@
import { extractLinearInlineMedia } from '../../shared/linear/inline-media'
import { getPublicFileUrlClient, isAuthError, type LinearClientForWorkspace } from './client'
const DESCRIPTION_QUERY = `query OrcaLinearDescriptionImages($id: String!) {
issue(id: $id) { description }
}`
export async function getDescriptionImageUrls(
entry: LinearClientForWorkspace,
issueId: string,
description: string | undefined
): Promise<Record<string, string> | undefined> {
const uploads = extractLinearInlineMedia(description, 'description').filter(
(media) => media.linearUpload
)
if (uploads.length === 0) {
return undefined
}
try {
const result = await getPublicFileUrlClient(entry).client.rawRequest<
{ issue?: { description?: string | null } | null },
Record<string, unknown>
>(DESCRIPTION_QUERY, { id: issueId })
const signed = extractLinearInlineMedia(result.data?.issue?.description, 'description').filter(
(media) => media.linearUpload
)
const signedByPath = new Map(signed.map((media) => [new URL(media.url).pathname, media.url]))
// Keep access signatures out of the editable Markdown and its save payload.
return Object.fromEntries(
uploads.flatMap((media) => {
const url = signedByPath.get(new URL(media.url).pathname)
return url ? [[media.url, url]] : []
})
)
} catch (error) {
if (isAuthError(error)) {
throw error
}
console.warn('[linear] description image URLs failed:', error)
return undefined
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { LinearClient } from '@linear/sdk'
import type { LinearComment } from '../../shared/linear/issue-types'
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError } from './client'
import { getClients, getPublicFileUrlClient, isAuthError } from './client'
import {
ATTACHMENT_BY_UUID_QUERY,
COMMENT_BY_UUID_QUERY,
@@ -184,7 +184,7 @@ export async function getIssueComments(
await acquire()
try {
const result = await entry.client.client.rawRequest<
const result = await getPublicFileUrlClient(entry).client.rawRequest<
LinearIssueCommentsResponse,
LinearRawVariables
>(ISSUE_COMMENTS_QUERY, { id: issueId })
+8 -1
View File
@@ -3,6 +3,7 @@ import type { LinearWorkspaceSelection } from '../../shared/linear/workspace-typ
import { acquire, release } from './linear-request-concurrency'
import { clearToken } from './linear-token-store'
import { getClients, isAuthError } from './client'
import { getDescriptionImageUrls } from './linear-description-images'
import {
ATTACHMENT_BY_UUID_QUERY,
COMMENT_BY_UUID_QUERY,
@@ -43,10 +44,16 @@ export async function getIssue(
await acquire()
try {
const issue = await entry.client.issue(id)
return await mapIssueForWorkspace(entry, issue, {
const mapped = await mapIssueForWorkspace(entry, issue, {
includeChildren: true,
includeProject: true
})
const descriptionImageUrls = await getDescriptionImageUrls(
entry,
mapped.id,
mapped.description
)
return { ...mapped, ...(descriptionImageUrls ? { descriptionImageUrls } : {}) }
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
@@ -6,6 +6,7 @@ import Placeholder from '@tiptap/extension-placeholder'
import { LoaderCircle } from 'lucide-react'
import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions'
import { setRichMarkdownImageResolverContext } from '@/components/editor/rich-markdown-image-context'
import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html'
import {
createRichMarkdownEditorCodec,
@@ -24,6 +25,7 @@ import {
type LinearIssueMarkdownDescriptionEditorProps = {
value: string
imageUrls?: Record<string, string>
onChange: (value: string) => void
onSave: (value: string) => void
density: 'page' | 'drawer'
@@ -46,6 +48,7 @@ function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) {
export function LinearIssueMarkdownDescriptionEditor({
value,
imageUrls,
onChange,
onSave,
density,
@@ -115,6 +118,12 @@ export function LinearIssueMarkdownDescriptionEditor({
)
useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled)
useEffect(() => {
if (editor) {
setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls })
}
}, [editor, imageUrls])
useEffect(() => {
editorRef.current = editor
}, [editor])
@@ -226,6 +226,7 @@ export function LinearIssueTextEditor({
<div className="relative">
<LinearIssueMarkdownDescriptionEditor
value={descriptionDraft}
imageUrls={issue.descriptionImageUrls}
onChange={updateDescriptionDraft}
onSave={saveDescriptionValue}
density={density}
@@ -44,6 +44,7 @@ import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
import { RichMarkdownTaskList } from './rich-markdown-task-list'
import { createCachedLowlight } from './rich-markdown-lowlight-cache'
import { documentResourceAccess } from '@/lib/local-file-access'
import { resolveRichMarkdownImageUrl } from './rich-markdown-image-context'
const lowlight = createCachedLowlight(createLowlight(common))
@@ -171,7 +172,7 @@ export function createRichMarkdownExtensions({
}
)
} else if (src) {
img.src = src
img.src = resolveRichMarkdownImageUrl(this.storage, src)
} else {
img.removeAttribute('src')
}
@@ -9,15 +9,36 @@ export type RichMarkdownImageRuntimeContext = Omit<RuntimeFileOperationArgs, 'co
export type RichMarkdownImageResolverContext = {
filePath: string
imageUrls?: Record<string, string>
runtimeContext?: RichMarkdownImageRuntimeContext
}
export type RichMarkdownImageResolverSettings = Parameters<typeof settingsForRuntimeOwner>[0]
type RichMarkdownImageUrls = Record<string, string>
function isRichMarkdownImageUrls(value: unknown): value is RichMarkdownImageUrls {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.values(value).every((url) => typeof url === 'string')
)
}
export function resolveRichMarkdownImageUrl(storage: Record<string, unknown>, src: string): string {
const imageUrls = storage.imageUrls
if (isRichMarkdownImageUrls(imageUrls) && Object.hasOwn(imageUrls, src)) {
return imageUrls[src] ?? src
}
return src
}
type RichMarkdownImageStorage = {
image?: {
contextVersion?: number
filePath: string
imageUrls?: Record<string, string>
reloadListeners?: Set<() => void>
runtimeContext?: RichMarkdownImageRuntimeContext
}
@@ -71,6 +92,7 @@ export function setRichMarkdownImageResolverContext(
}
const previousSignature = getRichMarkdownImageContextSignature({
filePath: imageStorage.filePath,
imageUrls: imageStorage.imageUrls,
runtimeContext: imageStorage.runtimeContext
})
const nextSignature = getRichMarkdownImageContextSignature(context)
@@ -81,6 +103,7 @@ export function setRichMarkdownImageResolverContext(
// Why: nodeViews need a cheap change signal because the markdown src can
// remain identical while the file/runtime resolver context changes.
imageStorage.filePath = context.filePath
imageStorage.imageUrls = context.imageUrls
imageStorage.runtimeContext = context.runtimeContext
imageStorage.contextVersion = (imageStorage.contextVersion ?? 0) + 1
storage.image = imageStorage
@@ -93,6 +116,7 @@ export function setRichMarkdownImageResolverContext(
function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolverContext): string {
return [
context.filePath,
JSON.stringify(context.imageUrls ?? {}),
context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client',
context.runtimeContext?.connectionId ?? 'local',
context.runtimeContext?.expectedExternalSshTargetId ?? '',
@@ -0,0 +1,63 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/react'
import { afterEach, describe, expect, it } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
const editors: Editor[] = []
const source = 'https://uploads.linear.app/workspace/image'
function createEditor() {
const codec = createRichMarkdownEditorCodec()
const editor = new Editor({
extensions: createRichMarkdownExtensions({ codec }),
content: `Before\n\n![Screenshot](${source})\n\nAfter`,
contentType: 'markdown'
})
document.body.append(editor.view.dom)
editors.push(editor)
return editor
}
afterEach(() => {
editors.splice(0).forEach((editor) => editor.destroy())
document.body.replaceChildren()
})
describe('signed images in rich Markdown', () => {
it('displays signed URLs while edits and serialization retain the source URL', () => {
const editor = createEditor()
const signed = `${source}?signature=temporary`
setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls: { [source]: signed } })
expect(editor.view.dom.querySelector('img')?.src).toBe(signed)
editor.commands.insertContentAt(1, 'Edited ')
expect(editor.getMarkdown()).toContain(`![Screenshot](${source})`)
expect(editor.getMarkdown()).toContain('Edited Before')
expect(editor.getMarkdown()).not.toContain('signature')
expect(editor.getHTML()).not.toContain('signature')
})
it('refreshes signatures without replacing the document or disturbing selection', () => {
const editor = createEditor()
editor.commands.setTextSelection(3)
const doc = editor.state.doc
for (const signature of ['first', 'refreshed']) {
const signed = `${source}?signature=${signature}`
setRichMarkdownImageResolverContext(editor, { filePath: '', imageUrls: { [source]: signed } })
expect(editor.view.dom.querySelector('img')?.src).toBe(signed)
expect(editor.state.doc).toBe(doc)
expect(editor.state.selection.from).toBe(3)
}
})
it('clears the previous issue mapping and leaves ordinary URLs alone', () => {
const editor = createEditor()
setRichMarkdownImageResolverContext(editor, {
filePath: '',
imageUrls: { [source]: `${source}?signature=temporary` }
})
setRichMarkdownImageResolverContext(editor, { filePath: '' })
expect(editor.view.dom.querySelector('img')?.src).toBe(source)
})
})
+1
View File
@@ -8,6 +8,7 @@ export type LinearIssue = {
title: string
branchName?: string
description?: string
descriptionImageUrls?: Record<string, string>
url: string
state: {
name: string
+170
View File
@@ -0,0 +1,170 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import type { LinearIssue } from '../../src/shared/linear/issue-types'
import type { LinearIssueUpdate } from '../../src/shared/issue-mutation-types'
declare global {
var __linearImageSaved: LinearIssueUpdate[] | undefined
}
const SOURCE = 'https://uploads.linear.app/test-workspace/screenshot.png'
const SIGNED = `${SOURCE}?signature=temporary`
const DESCRIPTION = `Screenshot from the issue:\n\n![Screenshot](${SOURCE})\n\nDescription text.`
const ISSUE: LinearIssue = {
id: 'private-images',
identifier: 'IMG-1',
workspaceId: 'test-workspace',
title: 'Images in Linear tasks',
description: DESCRIPTION,
url: 'https://linear.app/test/issue/IMG-1',
state: { name: 'Todo', type: 'unstarted', color: '' },
team: { id: 'team-1', name: 'Test', key: 'IMG' },
labels: [],
labelIds: [],
priority: 0,
updatedAt: '2026-10-06T00:00:00.000Z'
}
async function installFixture(app: ElectronApplication, signed: boolean): Promise<void> {
await app.evaluate(
({ ipcMain }, { issue, source, signedUrl, signed }) => {
const displayed = {
...issue,
id: signed ? 'after' : 'before',
...(signed ? { descriptionImageUrls: { [source]: signedUrl } } : {})
}
const saved: LinearIssueUpdate[] = []
for (const channel of [
'linear:getIssue',
'linear:issueComments',
'linear:updateIssue',
'linear:listTeams',
'linear:listIssues',
'linear:teamStates',
'linear:teamLabels',
'linear:teamMembers'
]) {
ipcMain.removeHandler(channel)
}
ipcMain.handle('linear:getIssue', () => displayed)
ipcMain.handle('linear:issueComments', () => [
{
id: 'comment-1',
body: `![Comment screenshot](${signed ? signedUrl : source})`,
createdAt: issue.updatedAt,
user: { displayName: 'Test user' }
}
])
ipcMain.handle('linear:updateIssue', (_event, args: { updates: LinearIssueUpdate }) => {
saved.push(args.updates)
return { ok: true }
})
ipcMain.handle('linear:listTeams', () => [])
ipcMain.handle('linear:listIssues', () => ({ items: [] }))
ipcMain.handle('linear:teamStates', () => [])
ipcMain.handle('linear:teamLabels', () => [])
ipcMain.handle('linear:teamMembers', () => [])
globalThis.__linearImageSaved = saved
},
{ issue: ISSUE, source: SOURCE, signedUrl: SIGNED, signed }
)
}
async function openIssue(page: Page, signed: boolean): Promise<void> {
await page.evaluate(
({ issue, signed }) => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
store.setState({
linearStatus: {
connected: true,
viewer: null,
workspaces: [],
activeWorkspaceId: 'test-workspace',
selectedWorkspaceId: 'test-workspace'
},
linearStatusChecked: true,
checkLinearConnection: async () => {}
})
store.getState().openTaskPage({
taskSource: 'linear',
openLinearIssue: { ...issue, id: signed ? 'after' : 'before' }
})
},
{ issue: ISSUE, signed }
)
await expect(page.getByLabel('Issue description', { exact: true })).toBeVisible()
await expect(page.getByRole('link', { name: 'Comment screenshot' })).toBeVisible()
}
test.use({ seedTestRepo: false })
test('private Linear images load without putting signatures into description edits', async ({
electronApp,
orcaPage
}, testInfo) => {
const image = readFileSync(join(process.cwd(), 'resources', 'icon.png'))
let unsignedRequests = 0
let signedRequests = 0
await orcaPage.route('https://uploads.linear.app/**', async (route) => {
if (new URL(route.request().url()).searchParams.get('signature') === 'temporary') {
signedRequests++
await route.fulfill({ status: 200, contentType: 'image/png', body: image })
} else {
unsignedRequests++
await route.fulfill({ status: 401, body: 'Unauthorized' })
}
})
await installFixture(electronApp, false)
await openIssue(orcaPage, false)
await expect.poll(() => unsignedRequests).toBeGreaterThanOrEqual(1)
await expect
.poll(() =>
orcaPage
.getByAltText('Screenshot', { exact: true })
.evaluate((img: HTMLImageElement) => img.complete && img.naturalWidth === 0)
)
.toBe(true)
await testInfo.attach('linear-images-before.png', {
body: await orcaPage.screenshot({ path: testInfo.outputPath('linear-images-before.png') }),
contentType: 'image/png'
})
await installFixture(electronApp, true)
await openIssue(orcaPage, true)
await expect
.poll(() =>
orcaPage
.getByAltText('Screenshot', { exact: true })
.evaluate((img: HTMLImageElement) => img.naturalWidth)
)
.toBeGreaterThan(0)
await expect(orcaPage.getByRole('link', { name: 'Comment screenshot' })).toHaveAttribute(
'href',
SIGNED
)
await expect.poll(() => signedRequests).toBeGreaterThanOrEqual(1)
await testInfo.attach('linear-images-after.png', {
body: await orcaPage.screenshot({ path: testInfo.outputPath('linear-images-after.png') }),
contentType: 'image/png'
})
const editor = orcaPage.getByLabel('Issue description', { exact: true })
await editor.click()
await editor.press('End')
await editor.press('ArrowRight')
await editor.press('Enter')
await editor.pressSequentially('Verified edit')
await orcaPage.getByRole('textbox', { name: 'Issue title' }).click()
await expect
.poll(() => electronApp.evaluate(() => globalThis.__linearImageSaved))
.toEqual([{ description: expect.stringContaining(`![Screenshot](${SOURCE})`) }])
const saved = await electronApp.evaluate(() => globalThis.__linearImageSaved)
expect(JSON.stringify(saved)).toContain('Verified edit')
expect(JSON.stringify(saved)).not.toContain('signature=')
})