Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
145d6b3
Fix `relative-time` error with invalid `navigator.language`
silverwind Apr 16, 2026
6bba33a
Add margin and green tint to copy-success feedback
silverwind Apr 16, 2026
986524b
Refactor showGlobalError to accept Error + options
silverwind Apr 16, 2026
991e3d9
Revert showGlobalErrorMessage API to string + optional stack
silverwind Apr 16, 2026
7a3668c
Address review feedback
silverwind Apr 16, 2026
fed4ff3
Drop redundant stack-for-copy test
silverwind Apr 16, 2026
7d99c9d
Shrink diff against main
silverwind Apr 16, 2026
60a6352
Rework error UI to use <details>/<summary>/<pre><code>
silverwind Apr 17, 2026
708ec77
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 17, 2026
131b7cd
fix
wxiaoguang Apr 18, 2026
c3a286e
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 19, 2026
e90c234
Address review: restore console hint, drop homepage e2e, simplify sta…
silverwind Apr 19, 2026
aaca6e7
Address review: drop text-center/whitespace, revert to simple append
silverwind Apr 20, 2026
03ebb69
Merge remote-tracking branch 'origin/main' into relative-time-invalid…
silverwind Apr 20, 2026
7cd7a9e
fix lint
silverwind Apr 20, 2026
8ec30f7
fix lint
wxiaoguang Apr 21, 2026
26bf1e4
Merge branch 'main' into relative-time-invalid-lang
silverwind Apr 21, 2026
ca6f7df
Merge branch 'main' into relative-time-invalid-lang
lunny Apr 21, 2026
ab30e9f
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
4a25a35
Merge branch 'main' into relative-time-invalid-lang
GiteaBot Apr 21, 2026
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
1 change: 1 addition & 0 deletions eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,7 @@ export default defineConfig([
rules: {
...playwright.configs['flat/recommended'].rules,
'playwright/expect-expect': [0],
'playwright/no-networkidle': [0],
},
},
{
Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/homepage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {test} from '@playwright/test';
import {assertNoJsError} from './utils.ts';

test('homepage renders without errors', async ({page}) => {
await page.goto('/');
Comment thread
silverwind marked this conversation as resolved.
Outdated
await page.waitForLoadState('networkidle');
await assertNoJsError(page);
});
Comment thread
silverwind marked this conversation as resolved.
Outdated
34 changes: 29 additions & 5 deletions web_src/js/modules/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
expect(isGiteaError('', '')).toBe(true);
Expand All @@ -16,12 +20,32 @@ test('isGiteaError', () => {
});

test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated

expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
expect(document.body.innerHTML).toContain('>test msg 2<');
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
const errs = document.querySelectorAll('.js-global-error');
expect(errs.length).toEqual(2);
expect(errs[0].querySelector('.js-global-error-msg')!.textContent).toBe('test msg 1');
expect(errs[0].querySelector('.js-global-error-count')!.textContent).toBe(' (2)');
expect(errs[1].querySelector('.js-global-error-msg')!.textContent).toBe('test msg 2');
expect(errs[1].querySelector('.js-global-error-count')!.textContent).toBe('');
});

test('processWindowErrorEvent renders stack trace', () => {
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-msg')!.textContent).toBe('JavaScript error: boom');
expect(document.querySelector('.js-global-error-stack')!.textContent).toContain('/assets/js/index.js:1:1');
});

test('processWindowErrorEvent falls back to message without stack', () => {
processWindowErrorEvent({
error: {message: 'script error'} as Error, type: 'error',
Comment thread
silverwind marked this conversation as resolved.
Outdated
filename: `${window.location.origin}/assets/js/x.js`, lineno: 5, colno: 10,
} as ErrorEvent & PromiseRejectionEvent);
const msgText = document.querySelector('.js-global-error-msg')!.textContent;
expect(msgText).toContain('JavaScript error: script error');
expect(msgText).toContain('@ 5:10');
});
51 changes: 38 additions & 13 deletions web_src/js/modules/errors.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// keep this file lightweight, it's imported into IIFE chunk in bootstrap
import {clippie} from 'clippie';
import {html} from '../utils/html.ts';
import octiconCheck from '../../../public/assets/img/svg/octicon-check.svg';
import octiconCopy from '../../../public/assets/img/svg/octicon-copy.svg';
Comment thread
silverwind marked this conversation as resolved.
Outdated
import type {Intent} from '../types.ts';

Comment thread
silverwind marked this conversation as resolved.
Outdated
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error', stack?: string) {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
Expand All @@ -12,14 +15,38 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
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>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
el.innerHTML = html`
<div class="ui container js-global-error tw-my-[--page-spacing]">
<div class="ui ${msgType} message tw-flex tw-justify-center tw-items-center">
<span class="js-global-error-msg tw-whitespace-pre-line"></span><span class="js-global-error-count"></span>
<button type="button" class="js-global-error-copy interact-bg tw-text-inherit tw-p-2 tw-rounded tw-ml-1"></button>
<pre class="js-global-error-stack tw-hidden"></pre>
</div>
</div>
`;
msgDiv = el.firstElementChild as HTMLDivElement;
const copyBtn = msgDiv.querySelector<HTMLButtonElement>('.js-global-error-copy')!;
copyBtn.innerHTML = octiconCopy;
let resetTimeout: ReturnType<typeof setTimeout> | undefined;
copyBtn.addEventListener('click', async () => {
const msgText = msgDiv!.querySelector('.js-global-error-msg')!.textContent;
const stackText = msgDiv!.querySelector('.js-global-error-stack')!.textContent;
if (!await clippie([msgText, stackText].filter(Boolean).join('\n'))) return;
copyBtn.innerHTML = octiconCheck;
copyBtn.classList.replace('tw-text-inherit', 'tw-text-green');
clearTimeout(resetTimeout);
resetTimeout = setTimeout(() => {
copyBtn.innerHTML = octiconCopy;
copyBtn.classList.replace('tw-text-green', 'tw-text-inherit');
}, 1500);
});
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message')!.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
const msgCount = Number(msgDiv.getAttribute('data-global-error-msg-count')) + 1;
msgDiv.setAttribute('data-global-error-msg-compact', msgCompact);
msgDiv.setAttribute('data-global-error-msg-count', String(msgCount));
msgDiv.querySelector('.js-global-error-msg')!.textContent = msg;
msgDiv.querySelector('.js-global-error-count')!.textContent = msgCount > 1 ? ` (${msgCount})` : '';
msgDiv.querySelector('.js-global-error-stack')!.textContent = stack ?? '';
msgContainer.prepend(msgDiv);
}

Expand All @@ -46,12 +73,10 @@ export function processWindowErrorEvent({error, reason, message, type, filename,
if (window.config.runModeIsProd) return;
}

// Filter out errors from browser extensions or other non-Gitea scripts.
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;
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})`;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}`, 'error', err?.stack);
Comment thread
silverwind marked this conversation as resolved.
Outdated
}
9 changes: 9 additions & 0 deletions web_src/js/webcomponents/relative-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ test('respects lang from parent element', async () => {
expect(getText(el)).toBe('vor 3 Tagen');
});

test('falls back when navigator.language is invalid', async () => {
vi.spyOn(navigator, 'language', 'get').mockReturnValue('undefined');
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');
vi.restoreAllMocks();
});

test('switches to datetime with P1D threshold', async () => {
const el = createRelativeTime(new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), {
lang: 'en-US',
Expand Down
11 changes: 6 additions & 5 deletions web_src/js/webcomponents/relative-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,13 +258,14 @@ class RelativeTime extends HTMLElement {
}

get #lang(): string {
const lang = this.closest('[lang]')?.getAttribute('lang');
if (lang) {
// navigator.language can be `undefined` or the string `"undefined"` in headless browsers
for (const candidate of [this.closest('[lang]')?.getAttribute('lang'), navigator.language]) {
if (!candidate) continue;
try {
return new Intl.Locale(lang).toString();
} catch { /* invalid locale, fall through */ }
return String(new Intl.Locale(candidate));
} catch {}
}
return navigator.language ?? 'en';
return 'en';
}

get second(): 'numeric' | '2-digit' | undefined {
Expand Down