mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* feat(feedback): attach images to feedback submissions
Users pasting screenshots into the feedback dialog were silently dropped:
the textarea had no paste handler, the IPC payload had no image field, and
the endpoint had nowhere to put one. Reports arrived saying "images
attached" with nothing attached, which is why feedback-sourced tickets
never have a screenshot to work from.
Adds paste, drag-drop, and a file picker with thumbnail previews (up to 4
images, 8 MB each, png/jpeg/webp/gif). Rejected files raise a toast rather
than disappearing — silent loss is the bug being fixed.
Images ride the existing multipart lane, which previously activated only
for crash diagnostic bundles. Crash submissions still drop images; that
lane already carries bundles and the server rejects them there.
When the server reports imagesDelivered: false the dialog says the
feedback sent but the images did not, instead of a blanket success. A 2xx
without the field counts as delivered so this keeps working against a
server that predates the field.
Requires the marketing-site half to deploy first.
* copy(feedback): shorten attachment hint to 'Attach up to 4 screenshots'
* fix(feedback): make dropped screenshots actually attach
Three defects that discarded a user's image without telling them — the exact
failure this feature exists to fix.
Drag-and-drop never worked. `DataTransfer.files` is empty until the drop
lands, so the dragenter guard always saw zero files and the highlight never
armed. Worse, preload consumes native file drops on document capture with
`stopPropagation()` and routes the paths to the editor, so React's `onDrop`
never ran at all: dropping a screenshot on the dialog opened it in an editor
behind the modal. The drop is now claimed one phase earlier on window capture
and scoped to the dialog element, and the highlight keys off the drag types
the OS advertises — matching useComposerFileDragOver and useSidebarProjectDrop.
`crypto.randomUUID()` is undefined in non-secure browser contexts (the LAN web
client over plain HTTP), so building draft ids with it rejected the read and
dropped every image in the batch with no message and an unhandled rejection.
Use createBrowserUuid, the repo's fallback for exactly this.
`readFeedbackImageFiles` had no rejection handler, so any read failure (file
removed after picking, permission error) silently lost the whole batch.
Also: capacity was checked against a ref mirroring committed state, so two
pastes landing during an in-flight read both saw room for four and the main
process then rejected the entire submission; in-flight batches now count
against capacity. And the non-en catalogs still carried the pre-amendment
English copy for the attachment hint.
* fix(feedback): close the prototype-chain hole in the image allow-list
`contentType in FEEDBACK_IMAGE_EXTENSIONS` walks the prototype chain, so
"constructor", "__proto__", "toString", "valueOf" and "hasOwnProperty" all
cleared the allow-list. feedbackImageFilename then indexed the same object and
named the upload after the inherited value — "feedback-image-1.function
Object() { [native code] }" — and the part went out with that content type.
Only reachable by invoking feedback:submit directly (the renderer screens
types with Array.includes), which is exactly the threat model this function's
own doc comment claims to cover. Object.hasOwn matches the 54 other uses in
the repo and is identical for the four real types.
The inherited values carry no quotes or CRLF, so this was a bypassed allow-list
and a malformed upload, not multipart header injection.
Adds unit coverage for the module, which had none, plus an IPC-level case; all
six new assertions fail against `in`.
* fix(feedback): accept the drag on dragover so the drop can fire
The window-capture drop interception only fires if something first
preventDefaults `dragover`. In Electron that comes free from preload's
document-capture handler, but the same renderer is served to browsers as
web-index.html, where `installWebPreloadApi` builds `window.api` in JS and
installs no drag listeners at all. Nothing else in the renderer
preventDefaults dragover for a native file drag.
So on the web client the dialog is not a valid drop target: `drop` never
fires and the browser falls back to its default action for a file dropped
on a page — it navigates the tab to the file, taking the user's typed
feedback with it. The new types-based dragenter guard makes this worse
than before, because the highlight now arms and invites the drop that the
old `files`-based guard could never light up.
Mirrors useSidebarProjectDrop.onDragOver, which the drop rework already
claimed to match. In Electron it is a harmless duplicate of the
preventDefault preload already applied.
* fix(feedback): revoke batch previews when a read rejects partway
readFeedbackImageFiles creates the object URL for each accepted file as it
goes. If a later file in the same batch fails `arrayBuffer()` — the
removed-after-picking case the new rejection handler was added for — the
whole promise rejects and the already-built drafts are never returned, so
nothing ever revokes their previews.
Each leaked URL pins its blob for the life of the renderer, up to three at
8 MB. Release them before rethrowing; the caller's rejection handler is
unaffected.
* fix(feedback): cancel non-image drops the dialog already accepted
dragover advertises copy for every native file drag over the dialog, but
drop only cancelled for images. On the web client an uncancelled drop
navigates the tab to the file, taking the typed feedback with it.
* fix(feedback): stop image validation from aborting crash reports
buildSubmitBody drops images on the crash lane, but validation ran
unconditionally, so a crash submission carrying an invalid image would
have failed outright over attachments that were never going to be sent —
losing a crash report the user needs delivered. Gate validation the same
way body construction is gated.
Not reachable today (the IPC handler forces submissionType 'feedback' and
internal crash callers pass no images), but the two gates disagreeing is a
trap for the next caller. Raised by CodeRabbit.
Also documents why the image lane deliberately skips the 5xx retry the
text lane performs: replaying up to 32 MiB on a flaky link costs more than
it saves, and the dialog preserves the draft and thumbnails on failure.
* fix(feedback): stop mutating the image-count ref during render
React Doctor fails CI on "Ref mutated during render": the count was
assigned in the component body, where React can discard or replay work
that never commits.
Read the committed count from the callback closure instead of a ref.
Syncing the ref in an effect (the suggested fix) would reintroduce the
race a previous commit removed — right after an add, the ref is stale-low
until the effect flushes, so a paste in that window over-accepts and the
main process rejects the whole submission. The closure value is always the
committed count, and pendingImageReadsRef still covers in-flight reads.
Costs a re-registration of the drop listeners per attach, which is the
same teardown the hook already does when the dialog opens or closes.
* fix(feedback): stop an unsupported pasted image from eating co-pasted text
The paste handler consumed the event whenever the clipboard held any
image/* file, but only the four allow-listed types can actually attach.
Pasting text alongside an SVG or BMP therefore lost the text and attached
nothing — a silent loss of the user's own input, in the dialog where they
are mid-sentence.
Consume the paste only when something is attachable. Unsupported types
still route through readFeedbackImageFiles for their rejection toast, so
nothing is dropped silently; the difference is that the default paste is
left alone when we have nothing to offer in exchange.
Extraction deliberately stays broad. Narrowing it there (as suggested by
review) would skip handleAddFiles entirely, and a file paste into a
textarea does nothing visible — the image would vanish with no feedback.
The drop path is untouched: it must keep cancelling every native file drop
or the browser navigates the tab to the file.
* fix(feedback): stop the dialog accepting more than the endpoint will take
The endpoint rejects reports over 5000 characters with a 400, which the
dialog surfaces as a generic "Failed to submit feedback. Please try again."
Nothing said length was the problem, so retrying could not help — the draft
survived but the user had no way to know what to change.
Cap the textarea at the same 5000 and show a counter once 500 characters
remain, so the limit is visible before it bites rather than after. The
counter stays hidden until then; an always-on count reads as a word limit
to hit.
Extracted rather than inlined: the dialog is already past the 300-line mark
React Doctor warns on.
* fix(feedback): prevent silent attachment loss
* fix(feedback): improve attachment failure feedback
* fix(feedback): bound attachment response parsing
* fix(feedback): surface response body timeouts
* fix(feedback): harden image delivery
* fix(feedback): bound image preview resources
* fix(feedback): honor atomic image delivery response
Production’s single-message feedback endpoint uploads text and images atomically, then returns 202 {"ok":true} without an imagesDelivered field. Treating that omission as false warned users that every successful production attachment had failed.
Treat a settled successful JSON response with ok: true and no image field as delivered. Explicit imagesDelivered: false still surfaces partial delivery, while malformed, oversized, aborted, and stalled bodies remain unconfirmed or fail through the existing response bound and timeout path.
612 lines
21 KiB
TypeScript
612 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { fetchMock, handlers } = vi.hoisted(() => ({
|
|
fetchMock: vi.fn(),
|
|
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
app: { getVersion: () => '1.2.3-test' },
|
|
ipcMain: {
|
|
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
|
|
handlers.set(channel, handler)
|
|
}),
|
|
removeHandler: vi.fn((channel: string) => handlers.delete(channel))
|
|
},
|
|
net: { fetch: (...args: unknown[]) => fetchMock(...args) }
|
|
}))
|
|
|
|
import { MAX_FEEDBACK_IMAGE_RESPONSE_BYTES } from './feedback-image-attachments'
|
|
import { registerFeedbackHandlers, submitFeedback } from './feedback'
|
|
|
|
function okResponse(): Response {
|
|
return { ok: true, status: 200 } as unknown as Response
|
|
}
|
|
|
|
function errorResponse(status: number): Response {
|
|
return { ok: false, status } as unknown as Response
|
|
}
|
|
|
|
function requestInit(callIndex = 0): RequestInit {
|
|
return fetchMock.mock.calls[callIndex]?.[1] as RequestInit
|
|
}
|
|
|
|
function postedBody(callIndex = 0): Record<string, unknown> {
|
|
return JSON.parse(String(requestInit(callIndex).body)) as Record<string, unknown>
|
|
}
|
|
|
|
function diagnosticSubmitArgs(): Parameters<typeof submitFeedback>[0] {
|
|
return {
|
|
feedback: '[Crash Report]\n\nDiagnostic log:\n- Status: attached',
|
|
feedbackWithoutDiagnosticBundle:
|
|
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
|
|
submissionType: 'crash',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
diagnosticBundle: {
|
|
bundleSubmissionId: 'bundleabcdefghijklmnop',
|
|
content: '{"type":"bundle-header"}\n',
|
|
bytes: 25,
|
|
spanCount: 1
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('submitFeedback', () => {
|
|
beforeEach(() => {
|
|
vi.useRealTimers()
|
|
handlers.clear()
|
|
fetchMock.mockReset()
|
|
fetchMock.mockResolvedValue(okResponse())
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('strips GitHub identity and anonymous contact fields when submitted anonymously', async () => {
|
|
const anonymousArgs = {
|
|
feedback: 'private bug report',
|
|
submitAnonymously: true,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: 'trusted@example.com',
|
|
anonymousGithubLogin: 'trusted-user',
|
|
anonymousEmail: 'trusted@example.com',
|
|
anonymousX: 'trusted'
|
|
}
|
|
await submitFeedback(anonymousArgs)
|
|
|
|
const body = postedBody()
|
|
expect(body).toMatchObject({
|
|
feedback: 'private bug report',
|
|
submissionType: 'feedback',
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
appVersion: '1.2.3-test'
|
|
})
|
|
expect(body).not.toHaveProperty('anonymousGithubLogin')
|
|
expect(body).not.toHaveProperty('anonymousEmail')
|
|
expect(body).not.toHaveProperty('anonymousX')
|
|
})
|
|
|
|
it('preserves verified GitHub identity when not submitted anonymously', async () => {
|
|
await submitFeedback({
|
|
feedback: 'public bug report',
|
|
submitAnonymously: false,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: 'trusted@example.com'
|
|
})
|
|
|
|
const body = postedBody()
|
|
expect(body).toMatchObject({
|
|
feedback: 'public bug report',
|
|
submissionType: 'feedback',
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: 'trusted@example.com',
|
|
appVersion: '1.2.3-test'
|
|
})
|
|
})
|
|
|
|
it('preserves crash submissions for the crash report lane', async () => {
|
|
await submitFeedback({
|
|
feedback: '[Crash Report]',
|
|
submissionType: 'crash',
|
|
submitAnonymously: false,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: null
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
|
|
expect(postedBody()).toMatchObject({
|
|
feedback: '[Crash Report]',
|
|
submissionType: 'crash',
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: null
|
|
})
|
|
})
|
|
|
|
it('attaches diagnostic bundles only to crash submissions', async () => {
|
|
const diagnosticBundle = {
|
|
bundleSubmissionId: 'bundleabcdefghijklmnop',
|
|
content: '{"type":"bundle-header"}\n',
|
|
bytes: 25,
|
|
spanCount: 1
|
|
}
|
|
await submitFeedback({
|
|
feedback: '[Crash Report]',
|
|
submissionType: 'crash',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
diagnosticBundle
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
await submitFeedback({
|
|
feedback: 'normal feedback',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
diagnosticBundle
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
|
|
const crashInit = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined
|
|
const feedbackInit = fetchMock.mock.calls[1]?.[1] as RequestInit | undefined
|
|
const crashFormData = crashInit?.body as FormData
|
|
expect(crashFormData).toBeInstanceOf(FormData)
|
|
expect(crashInit?.headers).toBeUndefined()
|
|
expect(crashFormData.get('submissionType')).toBe('crash')
|
|
expect(crashFormData.get('diagnosticBundleSubmissionId')).toBe(
|
|
diagnosticBundle.bundleSubmissionId
|
|
)
|
|
expect(crashFormData.get('diagnosticBundleBytes')).toBe(String(diagnosticBundle.bytes))
|
|
expect(crashFormData.get('diagnosticBundleSpanCount')).toBe(String(diagnosticBundle.spanCount))
|
|
const file = crashFormData.get('diagnosticBundleFile')
|
|
expect(file).toBeInstanceOf(Blob)
|
|
await expect((file as Blob).text()).resolves.toBe(diagnosticBundle.content)
|
|
expect(JSON.parse(String(feedbackInit?.body))).not.toHaveProperty('diagnosticBundle')
|
|
})
|
|
|
|
it('retries a rejected diagnostic attachment as report-only JSON on the website API', async () => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(413)).mockResolvedValueOnce(okResponse())
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: true,
|
|
diagnosticBundleFailure: { status: 413, error: 'status 413' }
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(requestInit(0).body).toBeInstanceOf(FormData)
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(requestInit(1).headers).toEqual({
|
|
'Content-Type': 'application/json'
|
|
})
|
|
expect(postedBody(1)).toMatchObject({
|
|
feedback:
|
|
'[Crash Report]\n\nDiagnostic log:\n- Status: not uploaded\n- Reason: attachment failed',
|
|
submissionType: 'crash'
|
|
})
|
|
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
|
})
|
|
|
|
it('retries a diagnostic attachment server error as report-only JSON on the website API', async () => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(502)).mockResolvedValueOnce(okResponse())
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: true,
|
|
diagnosticBundleFailure: { status: 502, error: 'status 502' }
|
|
})
|
|
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(requestInit(1).headers).toEqual({ 'Content-Type': 'application/json' })
|
|
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
|
})
|
|
|
|
it('retries a diagnostic attachment network error as report-only JSON on the website API', async () => {
|
|
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
|
|
fetchMock.mockResolvedValueOnce(okResponse())
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: true,
|
|
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
|
|
})
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
|
|
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
|
})
|
|
|
|
it('allows 60 seconds for a diagnostic attachment before retrying report-only JSON', async () => {
|
|
vi.useFakeTimers()
|
|
fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('runtime abort text')))
|
|
})
|
|
})
|
|
fetchMock.mockResolvedValueOnce(okResponse())
|
|
const result = submitFeedback(diagnosticSubmitArgs())
|
|
|
|
await vi.advanceTimersByTimeAsync(59_999)
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
|
|
await expect(result).resolves.toEqual({
|
|
ok: true,
|
|
diagnosticBundleFailure: { status: null, error: 'request timed out after 60 seconds' }
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(postedBody(1)).not.toHaveProperty('diagnosticBundle')
|
|
})
|
|
|
|
it('retries a proxy-rejected diagnostic attachment as website JSON', async () => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(403)).mockResolvedValueOnce(okResponse())
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: true,
|
|
diagnosticBundleFailure: { status: 403, error: 'status 403' }
|
|
})
|
|
expect(fetchMock.mock.calls[1]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
expect(requestInit(1).body).not.toBeInstanceOf(FormData)
|
|
})
|
|
|
|
it.each([401, 409, 429])(
|
|
'does not retry a diagnostic attachment rejected with status %s',
|
|
async (status) => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(status))
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: false,
|
|
status,
|
|
error: `status ${status}`
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
}
|
|
)
|
|
|
|
it('preserves attachment and report-only failures when the degraded retry fails', async () => {
|
|
fetchMock.mockRejectedValueOnce(new Error('attachment network failed'))
|
|
fetchMock.mockRejectedValueOnce(new Error('report-only network failed'))
|
|
|
|
await expect(submitFeedback(diagnosticSubmitArgs())).resolves.toEqual({
|
|
ok: false,
|
|
status: null,
|
|
error: 'report-only network failed',
|
|
diagnosticBundleFailure: { status: null, error: 'attachment network failed' }
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('retries the website API when the primary feedback request stalls', async () => {
|
|
vi.useFakeTimers()
|
|
fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('request aborted')))
|
|
})
|
|
})
|
|
fetchMock.mockResolvedValueOnce(okResponse())
|
|
|
|
const result = submitFeedback({
|
|
feedback: 'stalled primary',
|
|
submitAnonymously: false,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: 'trusted@example.com'
|
|
})
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
|
|
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({ ok: true })
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
'https://www.onorca.dev/v1/feedback',
|
|
'https://www.onorca.dev/v1/feedback'
|
|
])
|
|
})
|
|
|
|
it('does not retry a non-diagnostic 404', async () => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(404))
|
|
|
|
await expect(
|
|
submitFeedback({
|
|
feedback: 'missing feedback route',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null
|
|
})
|
|
).resolves.toEqual({ ok: false, status: 404, error: 'status 404' })
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
})
|
|
|
|
it('does not retry again when the website retry stalls after a primary server error', async () => {
|
|
vi.useFakeTimers()
|
|
fetchMock.mockResolvedValueOnce(errorResponse(500))
|
|
fetchMock.mockImplementationOnce((_url: string, init?: RequestInit) => {
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('retry aborted')))
|
|
})
|
|
})
|
|
|
|
const result = submitFeedback({
|
|
feedback: 'primary 500 and retry stalled',
|
|
submitAnonymously: false,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: 'trusted@example.com'
|
|
})
|
|
await vi.advanceTimersByTimeAsync(10_000)
|
|
|
|
await expect(Promise.race([result, Promise.resolve('pending')])).resolves.toEqual({
|
|
ok: false,
|
|
status: null,
|
|
error: 'status 500; retry: request timed out after 10 seconds'
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
'https://www.onorca.dev/v1/feedback',
|
|
'https://www.onorca.dev/v1/feedback'
|
|
])
|
|
})
|
|
|
|
it('preserves the primary status when a same-host retry also returns a server error', async () => {
|
|
fetchMock.mockResolvedValueOnce(errorResponse(502)).mockResolvedValueOnce(errorResponse(503))
|
|
|
|
await expect(
|
|
submitFeedback({
|
|
feedback: 'primary and retry both server errors',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null
|
|
})
|
|
).resolves.toEqual({
|
|
ok: false,
|
|
status: 503,
|
|
error: 'status 502; retry: status 503'
|
|
})
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('posts to the website API first so crash reports use the snippet-capable route', async () => {
|
|
await submitFeedback({
|
|
feedback: '[Crash Report]',
|
|
submissionType: 'crash',
|
|
submitAnonymously: true,
|
|
githubLogin: null,
|
|
githubEmail: null
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
|
|
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://www.onorca.dev/v1/feedback')
|
|
})
|
|
|
|
it('forces renderer IPC submissions onto the feedback lane', async () => {
|
|
registerFeedbackHandlers()
|
|
await handlers.get('feedback:submit')?.(null, {
|
|
feedback: 'not a crash report',
|
|
submissionType: 'crash',
|
|
submitAnonymously: false,
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: null
|
|
})
|
|
|
|
expect(postedBody()).toMatchObject({
|
|
feedback: 'not a crash report',
|
|
submissionType: 'feedback',
|
|
githubLogin: 'trusted-user',
|
|
githubEmail: null
|
|
})
|
|
})
|
|
|
|
describe('image attachments', () => {
|
|
function pngImage(bytes = 8): { contentType: string; data: Uint8Array } {
|
|
return { contentType: 'image/png', data: new Uint8Array(bytes).fill(1) }
|
|
}
|
|
|
|
function imageSubmitArgs(
|
|
images: { contentType: string; data: Uint8Array }[]
|
|
): Parameters<typeof submitFeedback>[0] {
|
|
return {
|
|
feedback: 'images attached',
|
|
submissionType: 'feedback',
|
|
githubLogin: 'someone',
|
|
githubEmail: null,
|
|
images
|
|
}
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return Response.json(body, { status: 202 })
|
|
}
|
|
|
|
it('sends attached images as multipart form parts', async () => {
|
|
await submitFeedback(imageSubmitArgs([pngImage(), pngImage()]))
|
|
|
|
const body = requestInit().body as FormData
|
|
expect(body).toBeInstanceOf(FormData)
|
|
expect(body.getAll('feedbackImage')).toHaveLength(2)
|
|
expect(body.get('feedback')).toBe('images attached')
|
|
// Why: multipart must not lose the enrichment fields the JSON lane sends.
|
|
expect(body.get('submissionType')).toBe('feedback')
|
|
expect(body.get('appVersion')).toBe('1.2.3-test')
|
|
})
|
|
|
|
it('keeps the JSON lane when nothing is attached', async () => {
|
|
await submitFeedback(imageSubmitArgs([]))
|
|
|
|
expect(requestInit().body).not.toBeInstanceOf(FormData)
|
|
expect(postedBody().feedback).toBe('images attached')
|
|
})
|
|
|
|
it('reports partial delivery when the server could not attach the images', async () => {
|
|
fetchMock.mockResolvedValue(jsonResponse({ ok: true, imagesDelivered: false }))
|
|
|
|
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
|
ok: true,
|
|
imagesDelivered: false
|
|
})
|
|
})
|
|
|
|
it('accepts the production atomic-success response when it omits the image result', async () => {
|
|
fetchMock.mockResolvedValue(jsonResponse({ ok: true }))
|
|
|
|
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
|
ok: true,
|
|
imagesDelivered: true
|
|
})
|
|
})
|
|
|
|
it('reports unconfirmed delivery for a settled non-JSON 2xx', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 202,
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token')
|
|
}
|
|
} as unknown as Response)
|
|
|
|
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
|
ok: true,
|
|
imagesDelivered: false
|
|
})
|
|
})
|
|
|
|
it('reports unconfirmed delivery when the response body aborts before the deadline', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 202,
|
|
json: async () => {
|
|
throw new TypeError('terminated')
|
|
}
|
|
} as unknown as Response)
|
|
|
|
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
|
ok: true,
|
|
imagesDelivered: false
|
|
})
|
|
expect(requestInit().signal).toMatchObject({ aborted: false })
|
|
})
|
|
|
|
it('bounds the image-delivery response body', async () => {
|
|
fetchMock.mockResolvedValue(
|
|
new Response('x'.repeat(MAX_FEEDBACK_IMAGE_RESPONSE_BYTES + 1), { status: 202 })
|
|
)
|
|
|
|
await expect(submitFeedback(imageSubmitArgs([pngImage()]))).resolves.toEqual({
|
|
ok: true,
|
|
imagesDelivered: false
|
|
})
|
|
})
|
|
|
|
it('fails a stalled delivery response body at the attachment timeout', async () => {
|
|
vi.useFakeTimers()
|
|
fetchMock.mockImplementation((_url: string, init?: RequestInit) =>
|
|
Promise.resolve({
|
|
ok: true,
|
|
status: 202,
|
|
json: () =>
|
|
new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener('abort', () => reject(new Error('body aborted')))
|
|
})
|
|
} as unknown as Response)
|
|
)
|
|
|
|
const result = submitFeedback(imageSubmitArgs([pngImage()]))
|
|
await vi.advanceTimersByTimeAsync(60_000)
|
|
|
|
await expect(result).resolves.toEqual({
|
|
ok: false,
|
|
status: null,
|
|
error: 'request timed out after 60 seconds'
|
|
})
|
|
expect(requestInit().signal).toMatchObject({ aborted: true })
|
|
})
|
|
|
|
it('rejects unsupported image types before any request is made', async () => {
|
|
const result = await submitFeedback(
|
|
imageSubmitArgs([{ contentType: 'application/pdf', data: new Uint8Array(4) }])
|
|
)
|
|
|
|
expect(result.ok).toBe(false)
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Why: the renderer screens types first, so this lane only matters for a
|
|
// renderer invoking the channel directly — the case the handler guards.
|
|
it('rejects a prototype member posing as a content type over IPC', async () => {
|
|
registerFeedbackHandlers()
|
|
const result = (await handlers.get('feedback:submit')?.(null, {
|
|
feedback: 'images attached',
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
images: [{ contentType: 'constructor', data: new Uint8Array(4).fill(1) }]
|
|
})) as { ok: boolean; error?: string }
|
|
|
|
expect(result).toMatchObject({ ok: false, error: 'Unsupported image type.' })
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects malformed IPC bytes before typed-array normalization', async () => {
|
|
registerFeedbackHandlers()
|
|
const result = (await handlers.get('feedback:submit')?.(null, {
|
|
feedback: 'images attached',
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
images: [{ contentType: 'image/png', data: '8388608' }]
|
|
})) as { ok: boolean; error?: string }
|
|
|
|
expect(result).toMatchObject({ ok: false, error: 'Invalid image attachment bytes.' })
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects oversized IPC batches before normalizing their entries', async () => {
|
|
registerFeedbackHandlers()
|
|
const result = (await handlers.get('feedback:submit')?.(null, {
|
|
feedback: 'images attached',
|
|
githubLogin: null,
|
|
githubEmail: null,
|
|
images: Array.from({ length: 5 }, () => ({
|
|
contentType: 'image/png',
|
|
data: '8388608'
|
|
}))
|
|
})) as { ok: boolean; error?: string }
|
|
|
|
expect(result).toMatchObject({ ok: false, error: 'Attach 4 images or fewer.' })
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects more images than the supported count', async () => {
|
|
const result = await submitFeedback(
|
|
imageSubmitArgs(Array.from({ length: 5 }, () => pngImage()))
|
|
)
|
|
|
|
expect(result.ok).toBe(false)
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not fail a crash report over images it was never going to send', async () => {
|
|
// Why: the crash lane discards images, so validating them there would
|
|
// abort a crash report the user needs delivered.
|
|
await submitFeedback({
|
|
...diagnosticSubmitArgs(),
|
|
images: Array.from({ length: 9 }, () => ({
|
|
contentType: 'application/pdf',
|
|
data: new Uint8Array(0)
|
|
}))
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
const body = requestInit().body as FormData
|
|
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
|
expect(body.get('submissionType')).toBe('crash')
|
|
})
|
|
|
|
it('drops images from crash submissions', async () => {
|
|
await submitFeedback({
|
|
...diagnosticSubmitArgs(),
|
|
images: [pngImage()]
|
|
} as Parameters<typeof submitFeedback>[0])
|
|
|
|
const body = requestInit().body as FormData
|
|
expect(body.getAll('feedbackImage')).toHaveLength(0)
|
|
expect(body.get('diagnosticBundleSubmissionId')).toBe('bundleabcdefghijklmnop')
|
|
})
|
|
})
|
|
})
|