From 142153a1316339515baeb865d6275bb3173b2ebf Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:46:57 +0200 Subject: [PATCH] Harden host access recovery for v2.6.2 --- docs/CHANGELOG.md | 12 +++ extension/background.js | 157 ++++++++++++++++++++++++------ extension/host-access.js | 104 +++++++++++++++++--- extension/popup.js | 34 ++++--- scripts/test-host-access.mjs | 38 ++++++-- scripts/test-site-access-help.mjs | 57 ----------- scripts/verify-release.mjs | 1 - 7 files changed, 278 insertions(+), 125 deletions(-) delete mode 100644 scripts/test-site-access-help.mjs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d52ae48..cfc558d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to the KoalaSync browser extension and relay server. --- +## [v2.6.2] — 2026-07-15 + +### Added +- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language. + +### Fixed +- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted. +- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state. +- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers. + +--- + ## [v2.6.1] — 2026-07-15 ### Fixed diff --git a/extension/background.js b/extension/background.js index 44ccfec..ce93f85 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,7 +4,7 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; -import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; +import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -170,8 +170,12 @@ function ensureState() { ], (data) => { clearTimeout(storageTimeout); if (data.expectedAcksCount !== undefined) expectedAcksCount = data.expectedAcksCount; - if (data.currentTabId !== undefined) currentTabId = data.currentTabId; - if (data.currentTabTitle !== undefined) currentTabTitle = data.currentTabTitle; + if (data.currentTabId !== undefined) currentTabId = normalizeTabId(data.currentTabId); + if (data.currentTabTitle !== undefined) { + currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' + ? data.currentTabTitle + : null; + } // Merge data from storage with any early-arriving state // New entries (added during boot) must stay at the top (index 0) if (data.logs) logs = [...logs, ...data.logs].slice(0, 200); @@ -1770,6 +1774,9 @@ function injectionFailureResponse(error) { } async function injectContentScript(tabId, { requestHostAccess = true } = {}) { + const normalizedTabId = normalizeTabId(tabId); + if (normalizedTabId === null) throw new Error('Invalid tab ID'); + tabId = normalizedTabId; let needsPageApiSeek = false; let pageApiSeekReady = false; let access = null; @@ -1817,9 +1824,18 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { // A temporary activeTab grant is intentionally allowed to win: even if // permissions.contains() reports false, a successful injection above is // valid. Only convert an actual injection failure into a host-access UX. - if (access?.originPattern && access.granted === false) { + try { + // The tab may have crossed origins between the initial permission + // check and executeScript(). Always report/request the current URL. + access = await inspectTabHostAccess(chrome, tabId); + } catch (_inspectionError) { + access = null; + } + const accessIsMissing = access?.granted === false + || (access?.granted === null && isHostAccessError(error)); + if (access?.originPattern && accessIsMissing) { const requestAdded = requestHostAccess - ? await addTabHostAccessRequest(chrome, tabId) + ? await addTabHostAccessRequest(chrome, tabId, access.originPattern) : false; throw createHostAccessRequiredError(access, requestAdded, error); } @@ -1827,31 +1843,61 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { } } +let pendingTargetMutation = Promise.resolve(); + +function mutatePendingTarget(operation) { + const result = pendingTargetMutation.catch(() => {}).then(operation); + pendingTargetMutation = result.catch(() => {}); + return result; +} + async function rememberPendingTarget(tabId, tabTitle, error) { - const previous = await chrome.storage.session.get('pendingTargetTabId'); - const previousTabId = normalizeTabId(previous.pendingTargetTabId); - if (previousTabId !== null && previousTabId !== tabId) { - await removeTabHostAccessRequest(chrome, previousTabId); - } - await chrome.storage.session.set({ - pendingTargetTabId: tabId, - pendingTargetTabTitle: tabTitle || null, - pendingTargetHost: error?.host || null, - pendingTargetOriginPattern: error?.originPattern || null + return mutatePendingTarget(async () => { + const previous = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetOriginPattern' + ]); + const previousTabId = normalizeTabId(previous.pendingTargetTabId); + const nextOriginPattern = error?.originPattern || null; + if (previousTabId !== null && ( + previousTabId !== tabId + || previous.pendingTargetOriginPattern !== nextOriginPattern + )) { + await removeTabHostAccessRequest( + chrome, + previousTabId, + previous.pendingTargetOriginPattern || null + ); + } + await chrome.storage.session.set({ + pendingTargetTabId: tabId, + pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null, + pendingTargetHost: error?.host || null, + pendingTargetOriginPattern: nextOriginPattern + }); }); } async function clearPendingTarget() { - const pending = await chrome.storage.session.get('pendingTargetTabId'); - const pendingTabId = normalizeTabId(pending.pendingTargetTabId); - if (pendingTabId !== null) { - await removeTabHostAccessRequest(chrome, pendingTabId); - } - await chrome.storage.session.set({ - pendingTargetTabId: null, - pendingTargetTabTitle: null, - pendingTargetHost: null, - pendingTargetOriginPattern: null + return mutatePendingTarget(async () => { + const pending = await chrome.storage.session.get([ + 'pendingTargetTabId', + 'pendingTargetOriginPattern' + ]); + const pendingTabId = normalizeTabId(pending.pendingTargetTabId); + if (pendingTabId !== null) { + await removeTabHostAccessRequest( + chrome, + pendingTabId, + pending.pendingTargetOriginPattern || null + ); + } + await chrome.storage.session.set({ + pendingTargetTabId: null, + pendingTargetTabTitle: null, + pendingTargetHost: null, + pendingTargetOriginPattern: null + }); }); } @@ -1862,7 +1908,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = } const activationGeneration = ++targetActivationGeneration; - const previousTabId = currentTabId; + const previousTabId = normalizeTabId(currentTabId); try { await injectContentScript(selectedTabId, { requestHostAccess }); @@ -1872,6 +1918,7 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = } currentTabId = null; currentTabTitle = null; + lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId) { resetAudioProcessingInTab(previousTabId); @@ -1882,10 +1929,17 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = roomIdleSince, lastContentHeartbeatAt: null }); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } updateBadgeStatus(); if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { await rememberPendingTarget(selectedTabId, tabTitle, error); + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_ACCESS_REQUIRED', + ...injectionFailureResponse(error) + }).catch(() => {}); } else { await clearPendingTarget(); } @@ -1897,15 +1951,23 @@ async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = return { status: 'superseded' }; } + await applyAudioSettingsToTab(selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } currentTabId = selectedTabId; - currentTabTitle = tabTitle || null; + currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); if (previousTabId && previousTabId !== selectedTabId) { resetAudioProcessingInTab(previousTabId); } - await applyAudioSettingsToTab(selectedTabId); - await clearPendingTarget(); await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -1950,6 +2012,36 @@ if (chrome.permissions?.onAdded?.addListener) { }); } +if (chrome.tabs?.onRemoved?.addListener) { + chrome.tabs.onRemoved.addListener((removedTabId) => { + ensureState().then(async () => { + const tabId = normalizeTabId(removedTabId); + if (tabId === null) return; + const pending = await chrome.storage.session.get('pendingTargetTabId'); + const isCurrent = normalizeTabId(currentTabId) === tabId; + const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId; + if (!isCurrent && !isPending) return; + + targetActivationGeneration++; + if (isCurrent) { + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + } + if (isPending) await clearPendingTarget(); + await chrome.storage.session.set({ + currentTabId, + currentTabTitle, + roomIdleSince, + lastContentHeartbeatAt + }); + updateBadgeStatus(); + chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {}); + }).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn')); + }); +} + function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) { chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', @@ -1964,7 +2056,8 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { - injectContentScript(tabId).then(() => { + activateTargetTab(tabId, currentTabTitle).then(response => { + if (response?.status !== 'ok') return; setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); @@ -2618,8 +2711,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { return true; } - injectContentScript(tabId).then(() => { - sendResponse({ status: 'ok' }); + activateTargetTab(tabId, currentTabTitle).then(response => { + sendResponse(response); }).catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); sendResponse(injectionFailureResponse(err)); diff --git a/extension/host-access.js b/extension/host-access.js index d47b1a8..9f801b1 100644 --- a/extension/host-access.js +++ b/extension/host-access.js @@ -1,9 +1,15 @@ export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required'; export function normalizeTabId(value) { - if (value === null || value === undefined || value === '') return null; - const tabId = Number(value); - return Number.isInteger(tabId) && tabId > 0 ? tabId : null; + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value > 0 ? value : null; + } + if (typeof value !== 'string') return null; + + const normalized = value.trim(); + if (!/^[1-9]\d*$/.test(normalized)) return null; + const tabId = Number(normalized); + return Number.isSafeInteger(tabId) ? tabId : null; } export function describeTabUrl(rawUrl) { @@ -34,7 +40,7 @@ export function describeTabUrl(rawUrl) { export async function inspectTabHostAccess(chromeApi, tabId) { const tab = await chromeApi.tabs.get(tabId); - const descriptor = describeTabUrl(tab?.url || ''); + const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || ''); if (!descriptor) { return { tab, @@ -50,10 +56,16 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } try { - const granted = await chromeApi.permissions.contains({ - origins: [descriptor.originPattern] - }); - return { tab, ...descriptor, granted: granted === true }; + const granted = await callBooleanPermissionMethod( + chromeApi, + 'contains', + { origins: [descriptor.originPattern] } + ); + return { + tab, + ...descriptor, + granted: granted === null ? null : granted === true + }; } catch (_e) { // Permission inspection is advisory. The actual script injection below // remains the source of truth, including temporary activeTab access. @@ -61,26 +73,94 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } } -export async function addTabHostAccessRequest(chromeApi, tabId) { +function callBooleanPermissionMethod(chromeApi, methodName, request) { + const method = chromeApi.permissions?.[methodName]; + if (typeof method !== 'function') return Promise.resolve(null); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + resolve(value === true); + }; + const fail = (error) => { + if (settled) return; + settled = true; + reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed'))); + }; + const callback = (value) => { + const lastError = chromeApi.runtime?.lastError; + if (lastError) { + fail(new Error(lastError.message || String(lastError))); + return; + } + finish(value); + }; + + try { + const result = method.call(chromeApi.permissions, request, callback); + if (result && typeof result.then === 'function') { + result.then(finish, fail); + } else if (typeof result === 'boolean') { + finish(result); + } + // Callback-based Chromium/Firefox APIs return undefined and settle + // through callback. Promise-based implementations settle above. + } catch (error) { + fail(error); + } + }); +} + +export function requestOriginPermission(chromeApi, originPattern) { + if (typeof originPattern !== 'string' || !originPattern) { + return Promise.resolve(null); + } + return callBooleanPermissionMethod( + chromeApi, + 'request', + { origins: [originPattern] } + ).catch(() => false); +} + +export function isHostAccessError(error) { + const message = typeof error?.message === 'string' ? error.message : String(error || ''); + return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message); +} + +function hostAccessRequestDetails(tabId, originPattern) { + const request = { tabId }; + if (typeof originPattern === 'string' && originPattern) { + request.pattern = originPattern; + } + return request; +} + +export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) { if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') { return false; } try { - await chromeApi.permissions.addHostAccessRequest({ tabId }); + await chromeApi.permissions.addHostAccessRequest( + hostAccessRequestDetails(tabId, originPattern) + ); return true; } catch (_e) { return false; } } -export async function removeTabHostAccessRequest(chromeApi, tabId) { +export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) { if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') { return false; } try { - await chromeApi.permissions.removeHostAccessRequest({ tabId }); + await chromeApi.permissions.removeHostAccessRequest( + hostAccessRequestDetails(tabId, originPattern) + ); return true; } catch (_e) { return false; diff --git a/extension/popup.js b/extension/popup.js index de7fae0..22abc97 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,6 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; +import { requestOriginPermission } from './host-access.js'; const elements = { @@ -591,16 +592,6 @@ function handleTargetTabResponse(response) { return false; } -async function requestSiteAccess(originPattern) { - if (!originPattern || typeof chrome.permissions?.request !== 'function') return null; - try { - return await chrome.permissions.request({ origins: [originPattern] }); - } catch (error) { - console.warn('[Popup] Host permission request failed:', error.message); - return false; - } -} - function selectTargetTab(tabId, tabTitle) { return new Promise(resolve => { chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => { @@ -1847,10 +1838,21 @@ if (elements.siteAccessRetry) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; } + const requestedOriginPattern = siteAccessOriginPattern; elements.siteAccessRetry.disabled = true; - await requestSiteAccess(siteAccessOriginPattern); - await selectTargetTab(tabId, tabTitle); - elements.siteAccessRetry.disabled = false; + elements.targetTab.disabled = true; + try { + await requestOriginPermission(chrome, requestedOriginPattern); + const selectedTabId = parseInt(elements.targetTab.value); + // permissions.onAdded may already have completed activation, or a + // newer selection may have replaced this request while it was open. + if (!siteAccessBlocked || selectedTabId !== tabId + || siteAccessOriginPattern !== requestedOriginPattern) return; + await selectTargetTab(tabId, tabTitle); + } finally { + elements.siteAccessRetry.disabled = false; + elements.targetTab.disabled = false; + } }); } @@ -2251,6 +2253,12 @@ chrome.runtime.onMessage.addListener((msg) => { } else if (msg.type === 'TARGET_TAB_READY') { hideSiteAccessNotice(); populateTabs(null, msg.tabId); + } else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') { + showSiteAccessNotice(msg.host, msg.originPattern); + populateTabs(null, msg.tabId); + } else if (msg.type === 'TARGET_TAB_CLEARED') { + hideSiteAccessNotice(); + populateTabs(); } else if (msg.type === 'PING_UPDATE') { updatePingDisplay(msg.ping); } else if (msg.type === 'HISTORY_UPDATE') { diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs index 42efae4..8866abd 100644 --- a/scripts/test-host-access.mjs +++ b/scripts/test-host-access.mjs @@ -7,8 +7,10 @@ import { addTabHostAccessRequest, describeTabUrl, inspectTabHostAccess, + isHostAccessError, normalizeTabId, - removeTabHostAccessRequest + removeTabHostAccessRequest, + requestOriginPermission } from '../extension/host-access.js'; assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required'); @@ -17,6 +19,11 @@ assert.equal(normalizeTabId(undefined), null); assert.equal(normalizeTabId(''), null); assert.equal(normalizeTabId(0), null); assert.equal(normalizeTabId('42'), 42); +assert.equal(normalizeTabId(true), null); +assert.equal(normalizeTabId([42]), null); +assert.equal(normalizeTabId('42.5'), null); +assert.equal(normalizeTabId(' 42 '), 42); +assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null); assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), { url: 'https://emby.example:8443/web/index.html', host: 'emby.example:8443', @@ -50,30 +57,41 @@ assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] }); let requestedTabId = null; const requestChrome = { permissions: { - addHostAccessRequest: async ({ tabId }) => { requestedTabId = tabId; } + addHostAccessRequest: async request => { requestedTabId = request; } } }; -assert.equal(await addTabHostAccessRequest(requestChrome, 42), true); -assert.equal(requestedTabId, 42); +assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true); +assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' }); assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false); let removedTabId = null; const removeRequestChrome = { permissions: { - removeHostAccessRequest: async ({ tabId }) => { removedTabId = tabId; } + removeHostAccessRequest: async request => { removedTabId = request; } } }; -assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42), true); -assert.equal(removedTabId, 42); +assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true); +assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' }); assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false); +assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true); +assert.equal(isHostAccessError(new Error('No tab with id: 42')), false); +const callbackPermissionChrome = { + runtime: {}, + permissions: { + request: (_request, callback) => { callback(true); } + } +}; +assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true); +assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null); + const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8'); assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/, 'SET_TARGET_TAB must await successful activation before acknowledging it'); -assert.match(background, /addTabHostAccessRequest\(chrome, tabId\)/, +assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/, 'failed injection must register Chrome host-access request'); assert.match(background, /retryPendingTarget\(\)/, 'pending target must resume after the user grants access'); @@ -87,11 +105,11 @@ assert.ok( activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'), 'a tab must not become current until its content script injection succeeds' ); -assert.match(background, /removeTabHostAccessRequest\(chrome, pendingTabId\)/, +assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/, 'clearing a pending target must also clear Chrome toolbar access requests'); assert.match(popup, /response\?\.status === 'host_permission_required'/, 'popup must render the structured host-access failure'); -assert.match(popup, /chrome\.permissions\.request\(\{ origins: \[originPattern\] \}\)/, +assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/, 'retry button must request withheld host access directly'); assert.match(popupHtml, /id="siteAccessNotice"/, 'popup must contain a persistent site-access notice'); diff --git a/scripts/test-site-access-help.mjs b/scripts/test-site-access-help.mjs deleted file mode 100644 index e9c8424..0000000 --- a/scripts/test-site-access-help.mjs +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env node - -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const read = relativePath => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); - -const template = read('website/template.html'); -const foundationCss = read('website/styles/foundation.css'); -const supportCss = read('website/styles/support.css'); -const helpPage = read('website/site-access-help.html'); -const build = read('website/build.cjs'); -const localesDir = path.join(repoRoot, 'website', 'locales'); - -assert.equal((template.match(/class="site-access-banner"/g) || []).length, 1, - 'localized landing template must contain exactly one support banner'); -assert.match(template, /href="\/site-access-help\.html"/, - 'every compiled language must link to the English root help page'); -assert.match(template, /\{\{SUPPORT_BANNER_TEXT\}\}/); -assert.match(template, /\{\{SUPPORT_BANNER_CTA\}\}/); -assert.match(foundationCss, /\.site-access-banner\s*\{/, - 'landing bundle must style the support banner without deferred CSS'); - -for (const file of fs.readdirSync(localesDir).filter(file => file.endsWith('.json'))) { - const locale = JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8')); - assert.ok(locale.SUPPORT_BANNER_TEXT?.trim(), `${file} is missing SUPPORT_BANNER_TEXT`); - assert.ok(locale.SUPPORT_BANNER_CTA?.trim(), `${file} is missing SUPPORT_BANNER_CTA`); -} - -assert.match(helpPage, /