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
64 changes: 59 additions & 5 deletions src/dialogs/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,78 @@ const os = require('os')
const i18n = require('i18next')
const dialog = require('./dialog')

// GitHub responds HTTP 414 (URI too long) on the issue-creation form around
// 8200 chars; 8000 leaves headroom for request line and headers.
const MAX_URL_LENGTH = 8000

// When forced to truncate, keep more from the tail than the head: daemon
// migration logs wrapped in `new Error(logs)` (see daemon/migration-prompt.js)
// put the real cause on the last lines (see issue #3147).
const TRUNCATION_STEPS = [
[5, 30],
[3, 25],
[2, 20],
[1, 15],
[1, 10],
[0, 8],
[0, 5],
[0, 3]
]

const issueTitle = (e) => {
const es = e.stack ? e.stack.toString() : 'unknown error, no stacktrace'
const firstLine = es.substr(0, Math.min(es.indexOf('\n'), 72))
const stack = e && e.stack ? e.stack.toString() : 'unknown error, no stacktrace'
const newlineIdx = stack.indexOf('\n')
const lineEnd = newlineIdx === -1 ? stack.length : newlineIdx
const firstLine = stack.slice(0, Math.min(lineEnd, 72))
return `[gui error report] ${firstLine}`
}

const issueTemplate = (e) => `<!-- 👉️ Please describe HERE what you were doing when this error happened. -->
const issueTemplate = (stack) => `<!-- 👉️ Please describe HERE what you were doing when this error happened. -->

- **Desktop**: ${app.getVersion()}
- **OS**: ${os.platform()} ${os.release()} ${os.arch()}
- **Electron**: ${process.versions.electron}
- **Chrome**: ${process.versions.chrome}

\`\`\`
${e.stack}
${stack}
\`\`\`
`

function truncateStack (stack, headLines, tailLines) {
const lines = stack.split('\n')
if (lines.length <= headLines + tailLines) return stack
const head = lines.slice(0, headLines)
const tail = lines.slice(lines.length - tailLines)
const omitted = lines.length - headLines - tailLines
return [
...head,
`... ${omitted} lines omitted ...`,
...tail
].join('\n')
}

function buildBugReportUrl (title, body) {
return `https://github.com/ipfs/ipfs-desktop/issues/new?labels=kind%2Fbug%2C+need%2Ftriage&template=bug_report.md&title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
}

// Returns an issue-creation URL within MAX_URL_LENGTH. Tries the full stack
// first, then shrinks via head+tail truncation, always preserving the tail.
function generateBugReportUrl (e) {
const title = issueTitle(e)
const stack = e && e.stack ? e.stack.toString() : 'unknown error, no stacktrace'

let url = buildBugReportUrl(title, issueTemplate(stack))
if (url.length <= MAX_URL_LENGTH) return url

for (const [h, t] of TRUNCATION_STEPS) {
url = buildBugReportUrl(title, issueTemplate(truncateStack(stack, h, t)))
if (url.length <= MAX_URL_LENGTH) return url
}

return url.slice(0, MAX_URL_LENGTH)
}

let hasErrored = false

function generateErrorIssueUrl (e) {
Expand Down Expand Up @@ -70,7 +124,7 @@ function generateErrorIssueUrl (e) {
}
}
// Something else, prefill new issue form with error details
return `https://github.com/ipfs/ipfs-desktop/issues/new?labels=kind%2Fbug%2C+need%2Ftriage&template=bug_report.md&title=${encodeURI(issueTitle(e))}&body=${encodeURI(issueTemplate(e))}`.substring(0, 1999)
return generateBugReportUrl(e)
}

/**
Expand Down
113 changes: 113 additions & 0 deletions test/unit/errors.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const proxyquire = require('proxyquire').noCallThru()
const { test, expect } = require('@playwright/test')

const electronMock = {
app: {
getVersion: () => '0.49.0',
relaunch: () => {},
exit: () => {},
getPath: () => '/tmp'
},
shell: {
openExternal: () => {},
openPath: () => {}
}
}

const i18nMock = { t: (key) => key }

const { generateErrorIssueUrl } = proxyquire('../../src/dialogs/errors', {
electron: electronMock,
i18next: i18nMock,
'./dialog': () => 0
})

const MAX_URL_LENGTH = 8000

function decodeBody (url) {
const match = url.match(/[?&]body=([^&]*)/)
return match ? decodeURIComponent(match[1]) : ''
}

function decodeTitle (url) {
const match = url.match(/[?&]title=([^&]*)/)
return match ? decodeURIComponent(match[1]) : ''
}

test.describe('generateErrorIssueUrl', () => {
test('returns FAQ link for known error patterns instead of a new-issue URL', () => {
const e = { stack: 'Error: repo.lock is held by another process' }
const url = generateErrorIssueUrl(e)
expect(url).toBe('https://github.com/ipfs/ipfs-desktop?tab=readme-ov-file#i-got-a-repolock-error-how-do-i-resolve-this')
})

test('produces a new-issue URL for unknown errors', () => {
const e = { stack: 'Error: something we have not seen before\n at fn (file.js:1:1)' }
const url = generateErrorIssueUrl(e)
expect(url).toContain('https://github.com/ipfs/ipfs-desktop/issues/new')
expect(url).toContain('title=')
expect(url).toContain('body=')
})

test('keeps URL within the 8000-char safety limit even for very long stacks', () => {
const longStack = 'Error: kaboom\n' + Array(2000).fill(' at someFunction (/very/long/path/to/file.js:123:45)').join('\n')
const url = generateErrorIssueUrl({ stack: longStack })
expect(url.length).toBeLessThanOrEqual(MAX_URL_LENGTH)
})

test('preserves the last lines of the stack when truncating (where daemon errors live)', () => {
const lastLine = 'Error: fs-repo-12-to-13/verify-repo-version: failed to verify repo'
const longStack = [
'Error: Initializing daemon...',
...Array(2000).fill('Fetching with HTTP: https://trustless-gateway.link/ipfs/Qm...'),
lastLine
].join('\n')
const url = generateErrorIssueUrl({ stack: longStack })
expect(url.length).toBeLessThanOrEqual(MAX_URL_LENGTH)
expect(decodeBody(url)).toContain(lastLine)
})

test('includes a marker indicating omitted lines when truncated', () => {
const longStack = 'Error: kaboom\n' + Array(2000).fill(' at fn (file.js:1:1)').join('\n')
const url = generateErrorIssueUrl({ stack: longStack })
expect(decodeBody(url)).toMatch(/\.\.\. \d+ lines omitted \.\.\./)
})

test('does not truncate stacks small enough to fit', () => {
const stack = 'Error: small\n at fn (file.js:1:1)\n at fn2 (file.js:2:2)'
const url = generateErrorIssueUrl({ stack })
expect(decodeBody(url)).toContain(stack)
expect(decodeBody(url)).not.toMatch(/lines omitted/)
})

test('issueTitle handles stacks without a newline', () => {
const url = generateErrorIssueUrl({ stack: 'short error message no newlines' })
expect(decodeTitle(url)).toBe('[gui error report] short error message no newlines')
})

test('issueTitle truncates very long single-line errors to 72 chars', () => {
const longLine = 'Error: ' + 'a'.repeat(200)
const url = generateErrorIssueUrl({ stack: longLine })
const title = decodeTitle(url)
expect(title.length).toBe('[gui error report] '.length + 72)
})

test('issueTitle handles missing stack gracefully', () => {
const url = generateErrorIssueUrl({})
expect(decodeTitle(url)).toBe('[gui error report] unknown error, no stacktrace')
})

test('properly encodes ampersand and other reserved chars in stack', () => {
const e = { stack: 'Error: foo & bar = baz # qux\n at fn (file.js:1:1)' }
const url = generateErrorIssueUrl(e)
// Query string must have exactly four params: labels, template, title, body.
// An unencoded & in the body would inflate this count.
const queryStart = url.indexOf('?')
const params = url.slice(queryStart + 1).split('&')
expect(params.length).toBe(4)
// The literal & should be percent-encoded inside body.
expect(url).toContain('%26')
// Decoded body should still contain the original chars.
expect(decodeBody(url)).toContain('foo & bar = baz # qux')
})
})
Loading