Fix relative-time error and improve global error handler (#37241)
1. Fixes: #37239 2. Enhance global error message to show stack trace on click --------- Signed-off-by: silverwind <me@silverwind.io> Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
{{template "devtest/devtest-header"}}
|
{{template "devtest/devtest-header"}}
|
||||||
<div class="ui container">
|
<div class="page-content">
|
||||||
<h1>Toast</h1>
|
<div data-global-init="initDevtestDetailsErrorMessage"></div>
|
||||||
<div>
|
<div class="ui container">
|
||||||
<button class="ui button toast-test-button" data-toast-level="info" data-toast-message="test info">Show Info Toast</button>
|
<h1>Toast</h1>
|
||||||
<button class="ui button toast-test-button" data-toast-level="warning" data-toast-message="test warning">Show Warning Toast</button>
|
<div>
|
||||||
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="test error">Show Error Toast</button>
|
<button class="ui button toast-test-button" data-toast-level="info" data-toast-message="test info">Show Info Toast</button>
|
||||||
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="very looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message">Show Error Toast (long)</button>
|
<button class="ui button toast-test-button" data-toast-level="warning" data-toast-message="test warning">Show Warning Toast</button>
|
||||||
|
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="test error">Show Error Toast</button>
|
||||||
|
<button class="ui button toast-test-button" data-toast-level="error" data-toast-message="very looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong message">Show Error Toast (long)</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{template "devtest/devtest-footer"}}
|
{{template "devtest/devtest-footer"}}
|
||||||
|
|||||||
@@ -12,6 +12,25 @@
|
|||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details.ui.message {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.ui.message summary {
|
||||||
|
padding: 1em 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.ui.message pre {
|
||||||
|
margin: -1.25em 0 0;
|
||||||
|
padding: 0.5em 1.5em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.ui.message:not(:has(pre)) summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
.ui.message:first-child {
|
.ui.message:first-child {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {registerGlobalInitFunc} from './observer.ts';
|
|||||||
import {fomanticQuery} from './fomantic/base.ts';
|
import {fomanticQuery} from './fomantic/base.ts';
|
||||||
import {createElementFromHTML} from '../utils/dom.ts';
|
import {createElementFromHTML} from '../utils/dom.ts';
|
||||||
import {html} from '../utils/html.ts';
|
import {html} from '../utils/html.ts';
|
||||||
|
import {showGlobalErrorMessage} from './errors.ts';
|
||||||
|
|
||||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||||
|
|
||||||
@@ -54,4 +55,10 @@ function initDevtestPage() {
|
|||||||
|
|
||||||
export function initDevtest() {
|
export function initDevtest() {
|
||||||
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
||||||
|
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
|
||||||
|
showGlobalErrorMessage('showGlobalErrorMessage message with details', 'error', `detail message 1\nvery lo${'o'.repeat(200)}ng line 2\nline 3`);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import {isGiteaError, showGlobalErrorMessage} from './errors.ts';
|
import {isGiteaError, processWindowErrorEvent, showGlobalErrorMessage} from './errors.ts';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div class="page-content"></div>';
|
||||||
|
});
|
||||||
|
|
||||||
test('isGiteaError', () => {
|
test('isGiteaError', () => {
|
||||||
expect(isGiteaError('', '')).toBe(true);
|
expect(isGiteaError('', '')).toBe(true);
|
||||||
@@ -16,7 +20,6 @@ test('isGiteaError', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('showGlobalErrorMessage', () => {
|
test('showGlobalErrorMessage', () => {
|
||||||
document.body.innerHTML = '<div class="page-content"></div>';
|
|
||||||
showGlobalErrorMessage('test msg 1');
|
showGlobalErrorMessage('test msg 1');
|
||||||
showGlobalErrorMessage('test msg 2');
|
showGlobalErrorMessage('test msg 2');
|
||||||
showGlobalErrorMessage('test msg 1'); // duplicated
|
showGlobalErrorMessage('test msg 1'); // duplicated
|
||||||
@@ -25,3 +28,21 @@ test('showGlobalErrorMessage', () => {
|
|||||||
expect(document.body.innerHTML).toContain('>test msg 2<');
|
expect(document.body.innerHTML).toContain('>test msg 2<');
|
||||||
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
|
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('processWindowErrorEvent renders stack trace in details', () => {
|
||||||
|
const error = new Error('boom');
|
||||||
|
error.stack = `Error: boom\n at fn (${window.location.origin}/assets/js/index.js:1:1)`;
|
||||||
|
processWindowErrorEvent({error, type: 'error'} as ErrorEvent & PromiseRejectionEvent);
|
||||||
|
expect(document.querySelector('.js-global-error summary')!.textContent).toContain('JavaScript error: boom');
|
||||||
|
expect(document.querySelector('.js-global-error pre')!.textContent).toContain('/assets/js/index.js:1:1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('processWindowErrorEvent falls back to message without stack', () => {
|
||||||
|
processWindowErrorEvent({
|
||||||
|
error: {message: 'script error'}, type: 'error',
|
||||||
|
filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10,
|
||||||
|
} as ErrorEvent & PromiseRejectionEvent);
|
||||||
|
const msgText = document.querySelector('.js-global-error .ui.message')!.textContent;
|
||||||
|
expect(msgText).toContain('JavaScript error: script error');
|
||||||
|
expect(msgText).toContain('@ 5:10');
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,25 +6,36 @@ export function errorMessage(err: unknown): string {
|
|||||||
return (err as Error)?.message || String(err);
|
return (err as Error)?.message || String(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
|
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', details?: string) {
|
||||||
const msgContainer = document.querySelector('.page-content') ?? document.body;
|
const parentContainer = document.querySelector('.page-content') ?? document.body;
|
||||||
if (!msgContainer) {
|
if (!parentContainer) {
|
||||||
alert(`${msgType}: ${msg}`);
|
alert(`${msgType}: ${msg}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
|
// compact the message to a data attribute to avoid too many duplicated messages
|
||||||
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
const msgCompact = `${msgType}-${msg.trim()}`.replace(/[^-\w\u{80}-\u{10FFFF}]+/gu, '');
|
||||||
if (!msgDiv) {
|
let msgContainer = parentContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
|
||||||
|
if (!msgContainer) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
|
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><details class="ui ${msgType} message"><summary></summary></details></div>`;
|
||||||
msgDiv = el.childNodes[0] as HTMLDivElement;
|
msgContainer = el.childNodes[0] as HTMLDivElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge duplicated messages into "the message (count)" format
|
// merge duplicated messages into "the message (count)" format
|
||||||
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
|
const msgCount = Number(msgContainer.getAttribute(`data-global-error-msg-count`)) + 1;
|
||||||
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
msgContainer.setAttribute(`data-global-error-msg-compact`, msgCompact);
|
||||||
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
msgContainer.setAttribute(`data-global-error-msg-count`, msgCount.toString());
|
||||||
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
|
||||||
msgContainer.prepend(msgDiv);
|
const msgElem = msgContainer.querySelector('details')!;
|
||||||
|
const msgSummary = msgElem.querySelector('summary')!;
|
||||||
|
msgSummary.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
|
||||||
|
if (details) {
|
||||||
|
let msgDetailsPre = msgElem.querySelector('pre');
|
||||||
|
if (!msgDetailsPre) msgDetailsPre = document.createElement('pre');
|
||||||
|
msgDetailsPre.textContent = details;
|
||||||
|
msgElem.append(msgDetailsPre);
|
||||||
|
}
|
||||||
|
parentContainer.prepend(msgContainer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect whether an error originated from Gitea's own scripts, not from
|
// Detect whether an error originated from Gitea's own scripts, not from
|
||||||
@@ -53,9 +64,9 @@ export function processWindowErrorEvent({error, reason, message, type, filename,
|
|||||||
// Filter out errors from browser extensions or other non-Gitea scripts.
|
// Filter out errors from browser extensions or other non-Gitea scripts.
|
||||||
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;
|
if (!isGiteaError(filename ?? '', err?.stack ?? '')) return;
|
||||||
|
|
||||||
let msg = err?.message ?? message;
|
|
||||||
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
|
||||||
const dot = msg.endsWith('.') ? '' : '.';
|
|
||||||
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
|
||||||
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
|
let msg = err?.message ?? message;
|
||||||
|
if (!err?.stack && lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
|
||||||
|
const dot = msg.endsWith('.') ? '' : '.';
|
||||||
|
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`, 'error', err?.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ test('respects lang from parent element', async () => {
|
|||||||
expect(getText(el)).toBe('vor 3 Tagen');
|
expect(getText(el)).toBe('vor 3 Tagen');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('falls back when navigator.language is invalid', async () => {
|
||||||
|
vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined');
|
||||||
|
try {
|
||||||
|
const el = document.createElement('relative-time');
|
||||||
|
el.setAttribute('datetime', new Date(Date.now() - 3 * 60 * 1000).toISOString());
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(getText(el)).toBe('3 minutes ago');
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('switches to datetime with P1D threshold', async () => {
|
test('switches to datetime with P1D threshold', async () => {
|
||||||
const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), {
|
const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), {
|
||||||
lang: 'en-US',
|
lang: 'en-US',
|
||||||
|
|||||||
@@ -258,13 +258,13 @@ class RelativeTime extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get #lang(): string {
|
get #lang(): string {
|
||||||
const lang = this.closest('[lang]')?.getAttribute('lang');
|
for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) {
|
||||||
if (lang) {
|
if (!candidate) continue;
|
||||||
try {
|
try {
|
||||||
return new Intl.Locale(lang).toString();
|
return String(new Intl.Locale(candidate));
|
||||||
} catch { /* invalid locale, fall through */ }
|
} catch {}
|
||||||
}
|
}
|
||||||
return navigator.language ?? 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
get second(): 'numeric' | '2-digit' | undefined {
|
get second(): 'numeric' | '2-digit' | undefined {
|
||||||
|
|||||||
Reference in New Issue
Block a user