diff --git a/src/App.vue b/src/App.vue index 09ae113e..baea8f5e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,7 @@ div#wrapper(v-if="loaded") import { useSettingsStore } from '~/stores/settings'; import { useServerStore } from '~/stores/server'; import { detectPreferredTheme } from '~/util/theme'; +import { setLocale } from '~/i18n'; // if vite is used, you can import css file as module //import darkCssUrl from '../static/dark.css?url'; //import darkCssContent from '../static/dark.css?inline'; @@ -33,12 +34,22 @@ export default { fullContainer() { return this.$route.meta.fullContainer; }, + language() { + return useSettingsStore().language; + }, + }, + + watch: { + language(language: string) { + setLocale(language); + }, }, async beforeCreate() { // Get Theme From LocalStorage const settingsStore = useSettingsStore(); await settingsStore.ensureLoaded(); + setLocale(settingsStore.language); const theme = settingsStore.theme; const detectedTheme = theme === 'auto' ? detectPreferredTheme() : theme; diff --git a/src/components/BucketMerge.vue b/src/components/BucketMerge.vue index 12bd5035..acadbb2e 100644 --- a/src/components/BucketMerge.vue +++ b/src/components/BucketMerge.vue @@ -1,43 +1,45 @@ diff --git a/src/components/BucketValidate.vue b/src/components/BucketValidate.vue index b817a0bc..b4269ea2 100644 --- a/src/components/BucketValidate.vue +++ b/src/components/BucketValidate.vue @@ -1,18 +1,18 @@ diff --git a/src/visualizations/VisTimeline.vue b/src/visualizations/VisTimeline.vue index 90501f76..f9c83c40 100644 --- a/src/visualizations/VisTimeline.vue +++ b/src/visualizations/VisTimeline.vue @@ -3,7 +3,7 @@ div#visualization div.small.text-muted.my-2(v-if="bucketsFromEither.length != 1") - i Buckets with no events in the queried range will be hidden. + i {{ $t('visualizationStatus.hiddenBuckets') }} div(v-if="editingEvent") EventEditor(:event="editingEvent" :bucket_id="editingEventBucket") @@ -60,6 +60,7 @@ import { getCategoryColorFromEvent, getTitleAttr } from '../util/color'; import { getSwimlane } from '../util/swimlane.js'; import { IEvent } from '../util/interfaces'; import { formatTimelineBucketLabelHtml, shortenBucketLabel } from '../util/timelineLabels'; +import { getLocale } from '~/i18n'; import { Timeline } from 'vis-timeline/esnext'; import 'vis-timeline/styles/vis-timeline-graph2d.css'; @@ -122,6 +123,9 @@ export default { }; }, computed: { + currentLocale() { + return getLocale(); + }, bucketsFromEither() { if (this.buckets) { return this.buckets; @@ -186,6 +190,9 @@ export default { this.update(); }, + currentLocale() { + this.update(); + }, }, mounted() { this.$nextTick(() => { @@ -271,8 +278,8 @@ export default { // edit flow. Persist the dismissal via localStorage so the user // doesn't see it every session. if (!this.editRefreshHintDismissed()) { - this.$bvToast.toast('Your edit is saved. Refresh the timeline to see it reflected.', { - title: 'Heads up', + this.$bvToast.toast(this.$t('visualizationStatus.refreshRequiredAfterEditToast'), { + title: this.$t('visualizationStatus.headsUp'), variant: 'info', autoHideDelay: 6000, solid: true, @@ -282,7 +289,11 @@ export default { isAlertWarningShown = true; } } else { - alert('selected multiple items: ' + JSON.stringify(properties.items)); + alert( + this.$t('visualizationStatus.selectedMultipleItems', { + items: JSON.stringify(properties.items), + }) + ); } }, abbreviateBucketName(bucketId: string): string { @@ -388,7 +399,10 @@ export default { if (groups.length > 0 && items.length > 0) { if (this.queriedInterval && this.showQueriedInterval) { const duration = this.queriedInterval[1].diff(this.queriedInterval[0], 'seconds'); - groups.push({ id: String(groups.length), content: 'queried interval' }); + groups.push({ + id: String(groups.length), + content: this.$t('visualizationStatus.queriedInterval'), + }); items.push({ id: String(items.length + 1), group: groups.length - 1, @@ -400,7 +414,7 @@ export default { data: { title: 'test' }, } ), - content: 'query', + content: this.$t('visualizationStatus.query'), start: this.queriedInterval[0], end: this.queriedInterval[1], style: 'background-color: #aaa; height: 10px', diff --git a/src/visualizations/periodusage.ts b/src/visualizations/periodusage.ts index eaebb547..100ab209 100644 --- a/src/visualizations/periodusage.ts +++ b/src/visualizations/periodusage.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import moment from 'moment'; import { seconds_to_duration, get_hour_offset } from '../util/time.ts'; +import { t } from '~/i18n'; function create(svg_elem: SVGElement) { // Clear element @@ -34,7 +35,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) { // No apps, sets status to "No data" if (usage_arr.length <= 0) { - set_status(svg_elem, 'No data'); + set_status(svg_elem, t('visualizationStatus.noData')); return; } svg_elem.innerHTML = ''; @@ -84,7 +85,7 @@ function update(svg_elem: SVGElement, usage_arr, onPeriodClicked) { .append('text') .attr('x', x + 1.5 * width + '%') .attr('y', '30') - .text('Today'); + .text(t('visualizationStatus.today')); } const rect = svg diff --git a/src/visualizations/summary.ts b/src/visualizations/summary.ts index f0f3dc5a..75ac26bc 100644 --- a/src/visualizations/summary.ts +++ b/src/visualizations/summary.ts @@ -8,6 +8,7 @@ import { useCategoryStore } from '~/stores/categories'; import { getCategoryColorFromString } from '~/util/color'; import { seconds_to_duration } from '~/util/time'; import { IEvent } from '~/util/interfaces'; +import { t } from '~/i18n'; const textColor = '#333'; @@ -49,7 +50,7 @@ interface Entry { function update(container: HTMLElement, apps: Entry[]) { // No apps, sets status to "No data" if (apps.length <= 0) { - set_status(container, 'No data'); + set_status(container, t('visualizationStatus.noData')); return container; } diff --git a/src/visualizations/timeline-simple.ts b/src/visualizations/timeline-simple.ts index 1e6ce6b9..78693ecf 100644 --- a/src/visualizations/timeline-simple.ts +++ b/src/visualizations/timeline-simple.ts @@ -6,6 +6,7 @@ const _ = require('lodash'); const moment = require('moment'); import { getTitleAttr, getColorFromString } from '../util/color'; +import { t } from '~/i18n'; const time = require('../util/time'); @@ -36,7 +37,7 @@ function update(svg_el, events, event_type: string) { timeline.selectAll('*').remove(); if (events.length <= 0) { - set_status(svg_el, 'No data'); + set_status(svg_el, t('visualizationStatus.noData')); return; } @@ -86,7 +87,8 @@ function update(svg_el, events, event_type: string) { .text( timestamp.format() + '\n' + - 'Duration: ' + + t('visualizationStatus.duration') + + ': ' + time.seconds_to_duration(e.duration) + '\n' + JSON.stringify(e.data) diff --git a/src/visualizations/timeline.ts b/src/visualizations/timeline.ts index 81b4f9e8..33458ea4 100644 --- a/src/visualizations/timeline.ts +++ b/src/visualizations/timeline.ts @@ -8,6 +8,7 @@ import _ from 'lodash'; import moment from 'moment'; import { getColorFromString } from '../util/color'; +import { t as translate } from '~/i18n'; import { seconds_to_duration } from '../util/time'; import { IEvent } from '../util/interfaces'; @@ -78,7 +79,7 @@ function update( d3.select(container.querySelector('.titleinfo-container')).html(null); if (events.length <= 0) { - set_status(container, 'No data'); + set_status(container, translate('visualizationStatus.noData')); return container; } diff --git a/test/unit/LanguageSettings.test.js b/test/unit/LanguageSettings.test.js new file mode 100644 index 00000000..10939c74 --- /dev/null +++ b/test/unit/LanguageSettings.test.js @@ -0,0 +1,53 @@ +import { shallowMount } from '@vue/test-utils'; +import { createTestingPinia } from '@pinia/testing'; +import LanguageSettings from '~/views/settings/LanguageSettings.vue'; +import { getLocale, installI18n, setLocale } from '~/i18n'; +import { useSettingsStore } from '~/stores/settings'; + +installI18n(); + +describe('LanguageSettings', () => { + beforeEach(() => { + setLocale('en'); + }); + + function mountLanguageSettings() { + return shallowMount(LanguageSettings, { + global: { + plugins: [ + createTestingPinia({ + initialState: { + settings: { + _loaded: true, + }, + }, + stubActions: true, + }), + ], + }, + stubs: { + 'b-select': { + template: '', + }, + }, + }); + } + + test('renders language options', () => { + const wrapper = mountLanguageSettings(); + + expect(wrapper.text()).toContain('Language'); + expect(wrapper.text()).toContain('English'); + expect(wrapper.text()).toContain('简体中文'); + }); + + test('updates the display language when a locale is selected', async () => { + const wrapper = mountLanguageSettings(); + const settingsStore = useSettingsStore(); + + await wrapper.find('select').setValue('zh-CN'); + + expect(settingsStore.update).toHaveBeenCalledWith({ language: 'zh-CN' }); + expect(getLocale()).toBe('zh-CN'); + }); +}); diff --git a/test/unit/i18n.test.node.ts b/test/unit/i18n.test.node.ts new file mode 100644 index 00000000..cb07acee --- /dev/null +++ b/test/unit/i18n.test.node.ts @@ -0,0 +1,183 @@ +import { + DEFAULT_LOCALE, + SUPPORTED_LOCALES, + getLocale, + installI18n, + interpolate, + setLocale, + t, + TranslationParams, + translate, +} from '~/i18n'; +import en from '~/i18n/locales/en'; +import zhCN from '~/i18n/locales/zh-CN'; +import Vue from 'vue'; + +type LocaleValue = string | LocaleTree; +type LocaleTree = { + [key: string]: LocaleValue; +}; + +function findExtraLocaleKeys(canonical: LocaleTree, candidate: LocaleTree, prefix = ''): string[] { + return Object.keys(candidate).flatMap(key => { + const path = prefix ? `${prefix}.${key}` : key; + + if (!Object.prototype.hasOwnProperty.call(canonical, key)) { + return [path]; + } + + const canonicalValue = canonical[key]; + const candidateValue = candidate[key]; + + if (typeof canonicalValue === 'string') { + return typeof candidateValue === 'string' ? [] : [path]; + } + + return typeof candidateValue === 'string' + ? [] + : findExtraLocaleKeys(canonicalValue, candidateValue, path); + }); +} + +describe('i18n', () => { + const originalVueT = Vue.prototype.$t; + + beforeEach(() => { + setLocale(DEFAULT_LOCALE); + }); + + afterEach(() => { + setLocale(DEFAULT_LOCALE); + + if (originalVueT === undefined) { + delete Vue.prototype.$t; + } else { + Vue.prototype.$t = originalVueT; + } + }); + + test('uses English as the default locale', () => { + expect(DEFAULT_LOCALE).toBe('en'); + expect(SUPPORTED_LOCALES).toContainEqual({ + code: 'en', + label: 'English', + }); + }); + + test('exposes supported locales as an immutable iterable list', () => { + expect(Array.from(SUPPORTED_LOCALES)).toEqual( + expect.arrayContaining([ + { code: 'en', label: 'English' }, + { code: 'zh-CN', label: '简体中文' }, + ]) + ); + expect(Object.isFrozen(SUPPORTED_LOCALES)).toBe(true); + expect(SUPPORTED_LOCALES.every(locale => Object.isFrozen(locale))).toBe(true); + expect(() => { + (SUPPORTED_LOCALES as unknown as Array<{ code: string; label: string }>).push({ + code: 'fr-FR', + label: 'French', + }); + }).toThrow(); + }); + + test.each([['zh-CN', zhCN]])('%s does not define keys outside the English schema', (_, locale) => { + expect(findExtraLocaleKeys(en, locale)).toEqual([]); + }); + + test('translates English keys', () => { + expect(t('common.loading')).toBe('Loading...'); + expect(translate('nav.activity', 'en')).toBe('Activity'); + }); + + test('translates zh-CN keys', () => { + expect(translate('common.loading', 'zh-CN')).toBe('加载中...'); + expect(translate('nav.activity', 'zh-CN')).toBe('活动'); + }); + + test('falls back to English when zh-CN key is missing', () => { + expect(translate('test.onlyEnglish', 'zh-CN')).toBe('Only English'); + }); + + test('returns the key when no locale contains the key', () => { + expect(translate('missing.key', 'zh-CN')).toBe('missing.key'); + }); + + test('does not resolve missing keys through the object prototype chain', () => { + expect(translate('constructor.name', 'en')).toBe('constructor.name'); + }); + + test('interpolates named params', () => { + expect(translate('activity.hostLabel', 'en', { host: 'laptop' })).toBe('Host: laptop'); + expect(translate('activity.hostLabel', 'zh-CN', { host: 'laptop' })).toBe('主机:laptop'); + }); + + test('t follows the active locale', () => { + setLocale('zh-CN'); + expect(t('nav.activity')).toBe('活动'); + setLocale('en'); + expect(t('nav.activity')).toBe('Activity'); + }); + + test('falls back to the default locale for invalid locale input', () => { + setLocale('zh-CN'); + + setLocale('fr-FR'); + expect(getLocale()).toBe(DEFAULT_LOCALE); + expect(t('nav.activity')).toBe('Activity'); + + setLocale('zh-CN'); + setLocale(null); + expect(getLocale()).toBe(DEFAULT_LOCALE); + + setLocale('zh-CN'); + setLocale(undefined); + expect(getLocale()).toBe(DEFAULT_LOCALE); + }); + + test('installs $t on Vue without requiring a Vue argument', () => { + delete Vue.prototype.$t; + + installI18n(); + + expect(Vue.prototype.$t('nav.activity')).toBe('Activity'); + }); + + test('updates Vue instance translations when the active locale changes', async () => { + installI18n(); + + const vm = new Vue({ + computed: { + activityLabel(this: Vue & { $t: typeof t }) { + return this.$t('nav.activity'); + }, + }, + }) as Vue & { activityLabel: string }; + + expect(vm.activityLabel).toBe('Activity'); + + setLocale('zh-CN'); + await Vue.nextTick(); + + expect(vm.activityLabel).toBe('活动'); + + vm.$destroy(); + }); + + test('keeps placeholders for missing params', () => { + expect(interpolate('Found {count} events in {seconds} seconds', { count: 5 })).toBe( + 'Found 5 events in {seconds} seconds' + ); + }); + + test('keeps placeholders for inherited or nullish params', () => { + const inheritedParams = Object.create({ name: 'inherited' }) as TranslationParams; + const explicitParams = Object.create(null) as TranslationParams; + explicitParams['constructor'] = 'explicit'; + + expect(interpolate('Missing {constructor}', {})).toBe('Missing {constructor}'); + expect(interpolate('Missing {name}', inheritedParams)).toBe('Missing {name}'); + expect(interpolate('Missing {name}', { name: null })).toBe('Missing {name}'); + expect(interpolate('Found {constructor}', explicitParams)).toBe('Found explicit'); + }); +}); diff --git a/test/unit/store/categories.test.node.ts b/test/unit/store/categories.test.node.ts index 323c4a52..05df80b0 100644 --- a/test/unit/store/categories.test.node.ts +++ b/test/unit/store/categories.test.node.ts @@ -1,6 +1,7 @@ import { isEqual } from 'lodash'; import { setActivePinia, createPinia } from 'pinia'; +import { setLocale } from '~/i18n'; import { useCategoryStore } from '~/stores/categories'; import { createMissingParents, defaultCategories, Category } from '~/util/classes'; @@ -9,6 +10,7 @@ describe('categories store', () => { const categoryStore = useCategoryStore(); beforeEach(() => { + setLocale('en'); categoryStore.clearAll(); }); @@ -45,6 +47,18 @@ describe('categories store', () => { expect(categoryStore.all_categories).toHaveLength(1); }); + test('translates category select metadata options without changing category names', () => { + categoryStore.load([{ name: ['Test'], rule: { type: 'none' } }]); + + setLocale('zh-CN'); + + expect(categoryStore.category_select(true).slice(0, 3)).toEqual([ + { text: '全部', value: null }, + { text: '未分类', value: ['Uncategorized'] }, + { text: 'Test', value: ['Test'] }, + ]); + }); + test('get category hierarchy', () => { categoryStore.restoreDefaultClasses(); const hier = categoryStore.classes_hierarchy; diff --git a/test/unit/store/settings.test.node.ts b/test/unit/store/settings.test.node.ts index ff1f32fc..2ceb2ec3 100644 --- a/test/unit/store/settings.test.node.ts +++ b/test/unit/store/settings.test.node.ts @@ -1,17 +1,98 @@ import { setActivePinia, createPinia } from 'pinia'; - import { useSettingsStore } from '~/stores/settings'; +import { DEFAULT_LOCALE, getLocale, setLocale } from '~/i18n'; -describe('settings store', () => { - setActivePinia(createPinia()); - const settingsStore = useSettingsStore(); +const App = require('~/App.vue').default; + +const postMock = jest.fn(); +const getSettingsMock = jest.fn(); +let consoleLogSpy: jest.SpyInstance; + +jest.mock('~/util/awclient', () => ({ + getClient: () => ({ + get_settings: getSettingsMock, + req: { + defaults: { + timeout: 0, + }, + post: postMock, + }, + }), +})); + +type MemoryStorage = Storage & { + store: Record; +}; +function createLocalStorage(items: Record = {}): MemoryStorage { + const storage = { + store: { ...items }, + get length() { + return Object.keys(this.store).length; + }, + clear() { + this.store = {}; + }, + getItem(key: string) { + return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null; + }, + key(index: number) { + return Object.keys(this.store)[index] ?? null; + }, + removeItem(key: string) { + delete this.store[key]; + }, + setItem(key: string, value: string) { + this.store[key] = String(value); + }, + }; + + return new Proxy(storage, { + get(target, property) { + if (typeof property === 'string' && property in target.store) { + return target.store[property]; + } + + return target[property]; + }, + ownKeys(target) { + return Reflect.ownKeys(target.store); + }, + getOwnPropertyDescriptor(target, property) { + if (typeof property === 'string' && property in target.store) { + return { + configurable: true, + enumerable: true, + value: target.store[property], + }; + } + + return undefined; + }, + }) as MemoryStorage; +} + +describe('settings store', () => { beforeEach(() => { - settingsStore.$reset(); - jest.restoreAllMocks(); + setActivePinia(createPinia()); + getSettingsMock.mockResolvedValue({}); + postMock.mockResolvedValue({}); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage(), + }); + setLocale(DEFAULT_LOCALE); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + jest.clearAllMocks(); + setLocale(DEFAULT_LOCALE); }); test('ensureLoaded coalesces concurrent loads', async () => { + const settingsStore = useSettingsStore(); let resolveLoad!: () => void; const loadMock = jest.spyOn(settingsStore, 'load').mockImplementation( () => @@ -35,6 +116,7 @@ describe('settings store', () => { }); test('update waits for settings to load before patching state', async () => { + const settingsStore = useSettingsStore(); const savedQueries = [ { id: 'daily-coding-time', @@ -63,4 +145,101 @@ describe('settings store', () => { expect(steps).toEqual(['ensureLoaded', 'save']); expect(settingsStore.saved_queries).toEqual(savedQueries); }); + + test('defaults to English', () => { + const settingsStore = useSettingsStore(); + expect(settingsStore.language).toBe('en'); + }); + + test('can update language in store state', () => { + const settingsStore = useSettingsStore(); + settingsStore.$patch({ language: 'zh-CN' }); + expect(settingsStore.language).toBe('zh-CN'); + }); + + test('loads supported language from server settings', async () => { + getSettingsMock.mockResolvedValue({ language: 'zh-CN' }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe('zh-CN'); + }); + + test('falls back to default language when server settings contain an unsupported language', async () => { + getSettingsMock.mockResolvedValue({ language: 'fr-FR' }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + }); + + test.each(['true', 'false'])( + 'falls back to default language when server settings contain boolean-like language %s', + async language => { + getSettingsMock.mockResolvedValue({ language }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + } + ); + + test('falls back to default language when localStorage contains an unsupported language', async () => { + getSettingsMock.mockResolvedValue({}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage({ language: 'fr-FR' }), + }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + }); + + test.each(['true', 'false'])( + 'falls back to default language when localStorage contains boolean-like language %s', + async language => { + getSettingsMock.mockResolvedValue({}); + Object.defineProperty(global, 'localStorage', { + configurable: true, + value: createLocalStorage({ language }), + }); + const settingsStore = useSettingsStore(); + + await settingsStore.load(); + + expect(settingsStore.language).toBe(DEFAULT_LOCALE); + } + ); + + test('saves language through the backend settings endpoint', async () => { + getSettingsMock.mockResolvedValue({}); + const settingsStore = useSettingsStore(); + settingsStore.$patch({ + _loaded: true, + language: 'zh-CN', + }); + + await settingsStore.save(); + + expect(postMock).toHaveBeenCalledWith('/0/settings/language', 'zh-CN', { + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + test('App language watcher syncs the active locale', () => { + const watcher = (App as any).watch.language; + + watcher('zh-CN'); + expect(getLocale()).toBe('zh-CN'); + + watcher('en'); + expect(getLocale()).toBe('en'); + }); }); diff --git a/test/unit/store/views.test.node.ts b/test/unit/store/views.test.node.ts index 9a1453d7..75c610b8 100644 --- a/test/unit/store/views.test.node.ts +++ b/test/unit/store/views.test.node.ts @@ -1,14 +1,22 @@ import { setActivePinia, createPinia } from 'pinia'; +import { setLocale } from '~/i18n'; +import { useSettingsStore } from '~/stores/settings'; import { useViewsStore } from '~/stores/views'; describe('views store', () => { - setActivePinia(createPinia()); - const viewsStore = useViewsStore(); + let viewsStore: ReturnType; beforeEach(() => { + setLocale('en'); + setActivePinia(createPinia()); + viewsStore = useViewsStore(); viewsStore.clearViews(); }); + afterEach(() => { + setLocale('en'); + }); + test('load default views', async () => { expect(viewsStore.views).toHaveLength(0); await viewsStore.load(); @@ -20,4 +28,25 @@ describe('views store', () => { viewsStore.loadViews([{ id: 'something', name: 'Something', elements: [] }]); expect(viewsStore.views).not.toHaveLength(0); }); + + test('loads default view names in zh-CN', async () => { + setLocale('zh-CN'); + + await viewsStore.load(); + + expect(viewsStore.views.map(view => view.name)).toContain('概览'); + }); + + test('saves localized default views with canonical English names', async () => { + setLocale('zh-CN'); + await viewsStore.load(); + + const settingsStore = useSettingsStore(); + await viewsStore.save(); + + expect(settingsStore.views.find(view => view.id === 'summary')).toMatchObject({ + name: 'Summary', + nameKey: 'views.summary', + }); + }); }); diff --git a/test/unit/tooltip.test.js b/test/unit/tooltip.test.js new file mode 100644 index 00000000..36642a81 --- /dev/null +++ b/test/unit/tooltip.test.js @@ -0,0 +1,39 @@ +import { buildTooltip } from '~/util/tooltip'; + +function buildWebTooltip(url) { + return buildTooltip( + { type: 'web.tab.current' }, + { + timestamp: '2024-01-01T12:00:00Z', + duration: 60, + data: { + title: 'Example', + url, + }, + } + ); +} + +function parseTooltip(html) { + const container = document.createElement('div'); + container.innerHTML = html; + return container; +} + +describe('buildTooltip', () => { + test('does not allow web URLs to inject attributes', () => { + const tooltip = parseTooltip(buildWebTooltip('https://example.com/" onclick="alert(1)')); + const anchor = tooltip.querySelector('a'); + + expect(anchor).not.toBeNull(); + expect(anchor.getAttribute('onclick')).toBeNull(); + }); + + test('does not render javascript URLs as clickable links', () => { + const tooltip = parseTooltip(buildWebTooltip('javascript:alert(1)')); + const anchor = tooltip.querySelector('a'); + + expect(anchor).toBeNull(); + expect(tooltip.textContent).toContain('javascript:alert(1)'); + }); +});