Update localization files + references (#4556)

* Initial plan

* Add localization support to NameChangeModal component

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* Add NameChangeModal translations to English language file

Co-authored-by: gabek <414923+gabek@users.noreply.github.com>

* fix(i18n): fix localization keys

* chore(test): add i18n test

* chore(i18n): update translation script

* chore(i18n): reorgnize translation keys and update components

* chore: fix linting warnings

* chore(i18n): update all the language files

* feat(i18n): add last live ago i18n key

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gabek <414923+gabek@users.noreply.github.com>
Co-authored-by: Gabe Kangas <gabek@real-ity.com>
This commit is contained in:
Copilot
2025-09-15 19:27:56 -07:00
committed by GitHub
co-authored by gabek copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Gabe Kangas
parent 401b5897e2
commit 1cf923a5af
46 changed files with 6053 additions and 4639 deletions
+660
View File
@@ -0,0 +1,660 @@
import fs from 'fs';
import path from 'path';
import { Localization } from '../types/localization';
/**
* Comprehensive localization test suite to verify that translation keys exist
* across multiple languages and that the localization system is working correctly.
*/
describe('Localization Keys Cross-Language Validation', () => {
const i18nDir = path.join(__dirname, '../i18n');
// Get all available language directories
const getAvailableLanguages = (): string[] =>
fs.readdirSync(i18nDir).filter(item => {
const itemPath = path.join(i18nDir, item);
return fs.statSync(itemPath).isDirectory() && item !== 'en'; // Exclude English as it's our reference
});
// Load translation file for a specific language
const loadTranslationFile = (language: string): Record<string, any> => {
try {
const translationPath = path.join(i18nDir, language, 'translation.json');
const content = fs.readFileSync(translationPath, 'utf-8');
return JSON.parse(content);
} catch {
return {};
}
};
// Helper function to get nested value from object using dot notation
const getNestedValue = (obj: Record<string, any>, key: string): any =>
key
.split('.')
.reduce(
(current, prop) => (current && current[prop] !== undefined ? current[prop] : undefined),
obj,
);
// Helper function to check if a key exists in a translation object
const keyExists = (translations: Record<string, any>, key: string): boolean =>
getNestedValue(translations, key) !== undefined;
// Load English translations as reference
const englishTranslations = loadTranslationFile('en');
const availableLanguages = getAvailableLanguages();
describe('Core Frontend Component Keys', () => {
const testKeys = [
// NameChangeModal keys
{
key: Localization.Frontend.NameChangeModal.description,
name: 'NameChangeModal.description',
},
{
key: Localization.Frontend.NameChangeModal.placeholder,
name: 'NameChangeModal.placeholder',
},
{ key: Localization.Frontend.NameChangeModal.buttonText, name: 'NameChangeModal.buttonText' },
{ key: Localization.Frontend.NameChangeModal.colorLabel, name: 'NameChangeModal.colorLabel' },
{ key: Localization.Frontend.NameChangeModal.authInfo, name: 'NameChangeModal.authInfo' },
{ key: Localization.Frontend.NameChangeModal.overLimit, name: 'NameChangeModal.overLimit' },
// Header component keys
{
key: Localization.Frontend.Header.skipToPlayer,
name: 'Header.skipToPlayer',
},
{
key: Localization.Frontend.Header.skipToOfflineMessage,
name: 'Header.skipToOfflineMessage',
},
{
key: Localization.Frontend.Header.skipToContent,
name: 'Header.skipToContent',
},
{
key: Localization.Frontend.Header.skipToFooter,
name: 'Header.skipToFooter',
},
{
key: Localization.Frontend.Header.chatWillBeAvailable,
name: 'Header.chatWillBeAvailable',
},
{
key: Localization.Frontend.Header.chatOffline,
name: 'Header.chatOffline',
},
// Footer component keys
{
key: Localization.Frontend.Footer.documentation,
name: 'Footer.documentation',
},
{
key: Localization.Frontend.Footer.contribute,
name: 'Footer.contribute',
},
{
key: Localization.Frontend.Footer.source,
name: 'Footer.source',
},
// BrowserNotifyModal keys (sample)
{
key: Localization.Frontend.BrowserNotifyModal.unsupported,
name: 'BrowserNotifyModal.unsupported',
},
{
key: Localization.Frontend.BrowserNotifyModal.allowButton,
name: 'BrowserNotifyModal.allowButton',
},
{
key: Localization.Frontend.BrowserNotifyModal.enabledTitle,
name: 'BrowserNotifyModal.enabledTitle',
},
{
key: Localization.Frontend.BrowserNotifyModal.mainDescription,
name: 'BrowserNotifyModal.mainDescription',
},
// Offline messages
{ key: Localization.Frontend.offlineBasic, name: 'Frontend.offlineBasic' },
{ key: Localization.Frontend.offlineNotifyOnly, name: 'Frontend.offlineNotifyOnly' },
// Error handling
{ key: Localization.Frontend.componentError, name: 'Frontend.componentError' },
];
test('should verify all test keys exist in English translation file', () => {
testKeys.forEach(({ key }) => {
const value = getNestedValue(englishTranslations, key);
expect(keyExists(englishTranslations, key)).toBe(true);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
testKeys.forEach(({ key, name }) => {
test(`should verify "${name}" exists across all available languages`, () => {
const missingLanguages: string[] = [];
const emptyTranslationLanguages: string[] = [];
availableLanguages.forEach(language => {
const translations = loadTranslationFile(language);
if (!keyExists(translations, key)) {
missingLanguages.push(language);
} else {
const value = getNestedValue(translations, key);
if (!value || value.trim() === '') {
emptyTranslationLanguages.push(language);
}
}
});
// Log warnings for missing translations but don't fail the test
if (missingLanguages.length > 0) {
console.warn(`⚠️ Key "${key}" is missing in languages: ${missingLanguages.join(', ')}`);
}
if (emptyTranslationLanguages.length > 0) {
console.warn(
`⚠️ Key "${key}" has empty translations in languages: ${emptyTranslationLanguages.join(', ')}`,
);
}
// At minimum, ensure the key exists in English
expect(keyExists(englishTranslations, key)).toBe(true);
});
});
});
describe('Admin Component Keys', () => {
const adminTestKeys = [
// EditInstanceDetails
{
key: Localization.Admin.EditInstanceDetails.offlineMessageDescription,
name: 'Admin.EditInstanceDetails.offlineMessageDescription',
},
{
key: Localization.Admin.EditInstanceDetails.directoryDescription,
name: 'Admin.EditInstanceDetails.directoryDescription',
},
{
key: Localization.Admin.EditInstanceDetails.serverUrlRequiredForDirectory,
name: 'Admin.EditInstanceDetails.serverUrlRequiredForDirectory',
},
// HardwareInfo
{
key: Localization.Admin.HardwareInfo.title,
name: 'Admin.HardwareInfo.title',
},
{
key: Localization.Admin.HardwareInfo.pleaseWait,
name: 'Admin.HardwareInfo.pleaseWait',
},
{
key: Localization.Admin.HardwareInfo.noDetails,
name: 'Admin.HardwareInfo.noDetails',
},
{
key: Localization.Admin.HardwareInfo.cpu,
name: 'Admin.HardwareInfo.cpu',
},
{
key: Localization.Admin.HardwareInfo.memory,
name: 'Admin.HardwareInfo.memory',
},
{
key: Localization.Admin.HardwareInfo.disk,
name: 'Admin.HardwareInfo.disk',
},
{
key: Localization.Admin.HardwareInfo.used,
name: 'Admin.HardwareInfo.used',
},
// Help page keys
{
key: Localization.Admin.Help.title,
name: 'Admin.Help.title',
},
{
key: Localization.Admin.Help.configureInstance,
name: 'Admin.Help.configureInstance',
},
{
key: Localization.Admin.Help.learnMore,
name: 'Admin.Help.learnMore',
},
{
key: Localization.Admin.Help.configureBroadcasting,
name: 'Admin.Help.configureBroadcasting',
},
{
key: Localization.Admin.Help.troubleshooting,
name: 'Admin.Help.troubleshooting',
},
{
key: Localization.Admin.Help.documentation,
name: 'Admin.Help.documentation',
},
{
key: Localization.Admin.Help.commonTasks,
name: 'Admin.Help.commonTasks',
},
// LogTable keys
{
key: Localization.Admin.LogTable.level,
name: 'Admin.LogTable.level',
},
{
key: Localization.Admin.LogTable.info,
name: 'Admin.LogTable.info',
},
{
key: Localization.Admin.LogTable.warning,
name: 'Admin.LogTable.warning',
},
{
key: Localization.Admin.LogTable.error,
name: 'Admin.LogTable.error',
},
{
key: Localization.Admin.LogTable.timestamp,
name: 'Admin.LogTable.timestamp',
},
{
key: Localization.Admin.LogTable.message,
name: 'Admin.LogTable.message',
},
{
key: Localization.Admin.LogTable.logs,
name: 'Admin.LogTable.logs',
},
// NewsFeed keys
{
key: Localization.Admin.NewsFeed.link,
name: 'Admin.NewsFeed.link',
},
{
key: Localization.Admin.NewsFeed.noNews,
name: 'Admin.NewsFeed.noNews',
},
{
key: Localization.Admin.NewsFeed.title,
name: 'Admin.NewsFeed.title',
},
// ViewerInfo keys
{
key: Localization.Admin.ViewerInfo.title,
name: 'Admin.ViewerInfo.title',
},
{
key: Localization.Admin.ViewerInfo.currentStream,
name: 'Admin.ViewerInfo.currentStream',
},
{
key: Localization.Admin.ViewerInfo.last12Hours,
name: 'Admin.ViewerInfo.last12Hours',
},
{
key: Localization.Admin.ViewerInfo.last24Hours,
name: 'Admin.ViewerInfo.last24Hours',
},
{
key: Localization.Admin.ViewerInfo.currentViewers,
name: 'Admin.ViewerInfo.currentViewers',
},
{
key: Localization.Admin.ViewerInfo.maxViewersThisStream,
name: 'Admin.ViewerInfo.maxViewersThisStream',
},
{
key: Localization.Admin.ViewerInfo.viewers,
name: 'Admin.ViewerInfo.viewers',
},
];
adminTestKeys.forEach(({ key, name }) => {
test(`should verify admin key "${name}" has appropriate translation structure`, () => {
const englishValue = getNestedValue(englishTranslations, key);
// Admin keys might have missing translation indicators
expect(englishValue).toBeDefined();
expect(typeof englishValue).toBe('string');
// Check if it's a missing translation placeholder
if (englishValue.includes('Missing translation')) {
// console.warn(
// `⚠️ Admin key "${key}" appears to have missing translation in English: ${englishValue}`,
// );
}
});
});
test('should identify missing admin keys in localization.ts vs translation files', () => {
const missingAdminKeys = [
{ key: Localization.Admin.emojis, name: 'Admin.emojis' },
{ key: Localization.Admin.settings, name: 'Admin.settings' },
{
key: Localization.Admin.Chat.moderationMessagesSent,
name: 'Admin.Chat.moderationMessagesSent',
},
];
missingAdminKeys.forEach(({ key, name }) => {
const englishValue = getNestedValue(englishTranslations, key);
if (!englishValue) {
console.warn(
`⚠️ Admin key "${name}" (${key}) is not present in translation files - consider adding it or removing from localization.ts`,
);
} else if (englishValue.includes('Missing translation')) {
console.warn(
`⚠️ Admin key "${name}" (${key}) has placeholder translation: ${englishValue}`,
);
}
});
// This test always passes but generates useful warnings
expect(true).toBe(true);
});
});
describe('Common Keys', () => {
const commonTestKeys = [
{ key: Localization.Common.poweredByOwncastVersion, name: 'Common.poweredByOwncastVersion' },
];
commonTestKeys.forEach(({ key, name }) => {
test(`should verify common key "${name}" exists in English`, () => {
expect(keyExists(englishTranslations, key)).toBe(true);
const value = getNestedValue(englishTranslations, key);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
});
});
test('should identify missing common keys in localization.ts vs translation files', () => {
// All Common keys are now properly used and extracted automatically
// Only poweredByOwncastVersion remains as it's actually used in Footer.tsx
const englishValue = getNestedValue(
englishTranslations,
Localization.Common.poweredByOwncastVersion,
);
expect(englishValue).toBeDefined();
expect(typeof englishValue).toBe('string');
// console.log(`✓ Common key "poweredByOwncastVersion" found: "${englishValue}"`);
});
});
describe('Legacy Frontend Keys (Direct String Values)', () => {
// These are keys that still use the old direct translation string approach
const legacyKeys = [
{
key: Localization.Frontend.chatDisabled,
name: 'chatDisabled',
expectedValue: 'Chat is disabled',
},
{
key: Localization.Frontend.currentViewers,
name: 'currentViewers',
expectedValue: 'Current viewers',
},
{ key: Localization.Frontend.connected, name: 'connected', expectedValue: 'Connected' },
{
key: Localization.Frontend.healthyStream,
name: 'healthyStream',
expectedValue: 'Healthy Stream',
},
{
key: Localization.Frontend.lastLiveAgo,
name: 'lastLiveAgo',
expectedValue: 'Last live {{timeAgo}} ago',
},
{
key: Localization.Frontend.maxViewers,
name: 'maxViewers',
expectedValue: 'Max viewers this stream',
},
];
legacyKeys.forEach(({ key, name, expectedValue }) => {
test(`should verify legacy frontend key "${name}" uses direct string value`, () => {
// These keys use direct string values instead of namespace keys
expect(key).toBe(expectedValue);
// But we should also verify they exist in the translation file for some languages
const value = getNestedValue(englishTranslations, key);
if (value) {
expect(typeof value).toBe('string');
}
});
});
});
describe('Localization Summary Report', () => {
test('should provide a concise summary of localization status', () => {
const criticalIssues: string[] = [];
const warnings: string[] = [];
// Check NameChangeModal keys (new feature)
const nameChangeKeys = [
Localization.Frontend.NameChangeModal.description,
Localization.Frontend.NameChangeModal.placeholder,
Localization.Frontend.NameChangeModal.buttonText,
];
const criticalLanguages = ['de', 'es', 'fr', 'it', 'ja', 'ru', 'zh'];
let missingCriticalTranslations = 0;
nameChangeKeys.forEach(key => {
criticalLanguages.forEach(lang => {
const langTranslations = loadTranslationFile(lang);
if (!keyExists(langTranslations, key)) {
missingCriticalTranslations++;
}
});
});
if (missingCriticalTranslations > 0) {
warnings.push(
`NameChangeModal needs translations in ${Math.floor(missingCriticalTranslations / nameChangeKeys.length)} major languages`,
);
}
// Check for keys that shouldn't be in localization.ts
const problematicKeys = [Localization.Admin.settings];
problematicKeys.forEach(key => {
if (!getNestedValue(englishTranslations, key)) {
criticalIssues.push(
`Key "${key}" exists in localization.ts but not in translation files`,
);
}
});
// Print summary
// console.log('\n🔍 Localization Status Summary:');
if (criticalIssues.length === 0 && warnings.length === 0) {
console.log('✅ All critical localization keys are properly configured');
} else {
if (criticalIssues.length > 0) {
console.log('❌ Critical Issues:');
criticalIssues.forEach(issue => console.log(` - ${issue}`));
}
if (warnings.length > 0) {
console.log('⚠️ Warnings:');
warnings.forEach(warning => console.log(` - ${warning}`));
}
}
// console.log(`📊 Languages supported: ${availableLanguages.length + 1} (including English)`);
// console.log('💡 Run with LOCALIZATION_VERBOSE=true for detailed warnings\n');
// Test always passes - this is just informational
expect(true).toBe(true);
});
});
describe('Language Coverage Report', () => {
test('should generate language coverage report for key components', () => {
const reportKeys = [
Localization.Frontend.NameChangeModal.placeholder,
Localization.Frontend.BrowserNotifyModal.allowButton,
Localization.Frontend.offlineBasic,
Localization.Common.poweredByOwncastVersion,
];
const report: Record<string, { total: number; missing: number; coverage: string }> = {};
availableLanguages.forEach(language => {
const translations = loadTranslationFile(language);
const missing = reportKeys.filter(key => !keyExists(translations, key)).length;
const total = reportKeys.length;
const coverage = (((total - missing) / total) * 100).toFixed(1);
report[language] = {
total,
missing,
coverage: `${coverage}%`,
};
});
// console.log('\n📊 Translation Coverage Report for Key Components:');
// console.log('Language\tCoverage\tMissing Keys');
// console.log('--------\t--------\t------------');
// Object.entries(report)
// .sort((a, b) => parseFloat(b[1].coverage) - parseFloat(a[1].coverage))
// .forEach(([lang, stats]) => {
// console.log(`${lang}\t\t${stats.coverage}\t\t${stats.missing}/${stats.total}`);
// });
// Test passes if we have the report data
expect(Object.keys(report).length).toBeGreaterThan(0);
});
});
describe('Translation File Structure Validation', () => {
test('should verify all language directories have translation.json files', () => {
availableLanguages.forEach(language => {
const translationPath = path.join(i18nDir, language, 'translation.json');
expect(fs.existsSync(translationPath)).toBe(true);
// Verify the file can be parsed as JSON
expect(() => {
const content = fs.readFileSync(translationPath, 'utf-8');
JSON.parse(content);
}).not.toThrow();
});
});
test('should verify English translation file has expected structure', () => {
expect(englishTranslations).toBeDefined();
expect(typeof englishTranslations).toBe('object');
// Check for expected top-level sections
expect(englishTranslations.Frontend).toBeDefined();
expect(englishTranslations.Common).toBeDefined();
// Check for specific component sections
expect(englishTranslations.Frontend.NameChangeModal).toBeDefined();
expect(englishTranslations.Frontend.BrowserNotifyModal).toBeDefined();
});
});
describe('Localization System Integration Test', () => {
test('should verify the translation hook works with our localization keys', () => {
// This test verifies that the actual translation system can resolve our keys
// We'll use a sample of our keys to test the integration
const testKeys = [
Localization.Frontend.NameChangeModal.placeholder,
Localization.Frontend.BrowserNotifyModal.allowButton,
Localization.Common.poweredByOwncastVersion,
];
testKeys.forEach(key => {
const value = getNestedValue(englishTranslations, key);
expect(value).toBeDefined();
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
});
test('should verify interpolation variables are correctly structured', () => {
// Test keys that should have interpolation variables
const interpolationTests = [
{
key: Localization.Frontend.componentError,
expectedVars: ['message'],
description: 'Component error message should interpolate {{message}}',
},
{
key: Localization.Common.poweredByOwncastVersion,
expectedVars: ['versionNumber'],
description: 'Powered by Owncast should interpolate {{versionNumber}}',
},
{
key: Localization.Frontend.offlineNotifyOnly,
expectedVars: ['streamer'],
description: 'Offline notify message should interpolate {{streamer}}',
},
];
interpolationTests.forEach(({ key, expectedVars }) => {
const value = getNestedValue(englishTranslations, key);
expect(value).toBeDefined();
expectedVars.forEach(varName => {
const hasVariable = value.includes(`{{${varName}}}`);
expect(hasVariable).toBe(true);
});
});
});
});
describe('Localization.ts Type Safety', () => {
test('should verify localization keys match expected patterns', () => {
// Test that nested component keys follow the namespace pattern
expect(Localization.Frontend.NameChangeModal.placeholder).toMatch(
/^Frontend\.NameChangeModal\./,
);
expect(Localization.Frontend.BrowserNotifyModal.allowButton).toMatch(
/^Frontend\.BrowserNotifyModal\./,
);
expect(Localization.Admin.Chat.moderationMessagesSent).toMatch(/^Admin\.Chat\./);
expect(Localization.Common.poweredByOwncastVersion).toMatch(/^Common\./);
// Test that basic frontend keys are direct translation strings
expect(typeof Localization.Frontend.chatOffline).toBe('string');
expect(typeof Localization.Frontend.currentViewers).toBe('string');
expect(typeof Localization.Frontend.connected).toBe('string');
});
test('should verify all localization keys are strings', () => {
const validateKeys = (obj: any, keyPath = ''): void => {
Object.entries(obj).forEach(([key, value]) => {
const currentPath = keyPath ? `${keyPath}.${key}` : key;
if (typeof value === 'object' && value !== null) {
validateKeys(value, currentPath);
} else {
expect(typeof value).toBe('string');
expect(value).toBeTruthy(); // Ensure no empty strings
}
});
};
validateKeys(Localization);
});
});
});
+28 -30
View File
@@ -7,43 +7,41 @@ import { Localization } from '../types/localization';
jest.mock('next-export-i18n', () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, any>) => {
// Use the actual keys as in localization.ts and translation.json
const translations: Record<string, string> = {
// Simulate the actual translation structure from the JSON files
const translations: Record<string, any> = {
// Frontend translations
'Frontend.helloWorld': 'Hello <strong>{{name}}</strong>, welcome to the world!',
'Frontend.componentError': 'Error: {{message}}',
'Frontend.offlineBasic': 'This stream is offline. Check back soon!',
'Frontend.offlineNotifyOnly':
"This stream is offline. <span class='notify-link'>Be notified</span> the next time {{streamer}} goes live.",
// Testing translations
'Testing.simpleKey': 'Simple translation text',
'Testing.itemCount': 'You have {{count}} items',
'Testing.itemCount_one': 'You have {{count}} item',
'Testing.messageCount': 'You have {{count}} messages from {{sender}}',
'Testing.messageCount_one': 'You have {{count}} message from {{sender}}',
'Testing.noPluralKey': 'This key has no plural variants - {{count}} things',
// Legacy flat keys for backwards compatibility
hello_world: 'Hello <strong>{{name}}</strong>, welcome to the world!',
chat_offline: 'Chat is offline',
notification_message:
'You can <a href="#">click here</a> to receive notifications when {{streamer}} goes live.',
component_error: 'Error: {{message}}',
offline_basic: 'This stream is offline. Check back soon!',
// Testing keys
'Testing.simpleKey': 'Simple translation text',
'Testing.itemCount_one': 'You have {{count}} item',
'Testing.itemCount': 'You have {{count}} items',
'Testing.messageCount_one': 'You have {{count}} message from {{sender}}',
'Testing.messageCount': 'You have {{count}} messages from {{sender}}',
'Testing.noPluralKey': 'This key has no plural variants - {{count}} things',
};
let result = translations[key];
// If not found, try to fallback to a snake_case version (for legacy or fallback)
if (!result && key.includes('.')) {
const [ns, k] = key.split('.');
// Try snake_case
const snakeKey = `${ns}.${k
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '')}`;
result = translations[snakeKey];
}
// If still not found, return the key itself
// If not found, return the key itself (as real i18n would do)
if (!result) {
result = key;
}
// Simple variable replacement for testing
if (vars) {
if (vars && typeof result === 'string') {
Object.keys(vars).forEach(varKey => {
result = result.replace(new RegExp(`{{${varKey}}}`, 'g'), vars[varKey]);
});
@@ -95,18 +93,18 @@ describe('Translation Component', () => {
expect(element).toHaveClass('custom-class');
});
test('should render notification message with HTML link', () => {
test('should render notification message with HTML content', () => {
render(
<Translation
translationKey={Localization.Frontend.notificationMessage}
translationKey={Localization.Frontend.offlineNotifyOnly}
vars={{ streamer: 'TestStreamer' }}
/>,
);
// Check that the link is rendered
const linkElement = screen.getByText('click here');
expect(linkElement.tagName).toBe('A');
expect(linkElement).toHaveAttribute('href', '#');
// Check that the HTML content is rendered
const linkElement = screen.getByText('Be notified');
expect(linkElement.tagName).toBe('SPAN');
expect(linkElement).toHaveClass('notify-link');
// Check that the variable is interpolated
expect(screen.getByText(/TestStreamer/)).toBeInTheDocument();
@@ -115,7 +113,7 @@ describe('Translation Component', () => {
test('should render with all props combined', () => {
render(
<Translation
translationKey={Localization.Frontend.notificationMessage}
translationKey={Localization.Frontend.offlineNotifyOnly}
vars={{ streamer: 'TestStreamer' }}
className="notification-style"
/>,
@@ -125,7 +123,7 @@ describe('Translation Component', () => {
const element = screen.getByText((_, e) => {
const hasText =
e?.textContent ===
'You can click here to receive notifications when TestStreamer goes live.';
'This stream is offline. Be notified the next time TestStreamer goes live.';
const isSpan = e?.tagName === 'SPAN';
return hasText && isSpan;
});