diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/base/content/test/static | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/base/content/test/static')
10 files changed, 2774 insertions, 0 deletions
diff --git a/browser/base/content/test/static/browser.ini b/browser/base/content/test/static/browser.ini new file mode 100644 index 0000000000..69f2a71723 --- /dev/null +++ b/browser/base/content/test/static/browser.ini @@ -0,0 +1,22 @@ +[DEFAULT] +# These tests can be prone to intermittent failures on slower systems. +# Since the specific flavor doesn't matter from a correctness standpoint, +# just skip the tests on sanitizer, debug and OS X verify builds. +skip-if = (asan || tsan || debug || (verify && os == 'mac')) +support-files = + head.js + +[browser_all_files_referenced.js] +skip-if = verify && bits == 32 # Causes OOMs when run repeatedly +[browser_misused_characters_in_strings.js] +support-files = + bug1262648_string_with_newlines.dtd +skip-if = os == 'win' && msix # Permafail on MSIX packages due to it running on files it shouldn't. +[browser_parsable_css.js] +support-files = + dummy_page.html +skip-if = os == 'win' && msix # Permafail on MSIX packages due to it running on files it shouldn't. +[browser_parsable_script.js] +skip-if = ccov && os == 'linux' # https://bugzilla.mozilla.org/show_bug.cgi?id=1608081 +[browser_sentence_case_strings.js] +[browser_title_case_menus.js] diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js new file mode 100644 index 0000000000..df8a1997a7 --- /dev/null +++ b/browser/base/content/test/static/browser_all_files_referenced.js @@ -0,0 +1,1093 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Note to run this test similar to try server, you need to run: +// ./mach package +// ./mach mochitest --appname dist <path to test> + +// Slow on asan builds. +requestLongerTimeout(5); + +var isDevtools = SimpleTest.harnessParameters.subsuite == "devtools"; + +// This list should contain only path prefixes. It is meant to stop the test +// from reporting things that *are* referenced, but for which the test can't +// find any reference because the URIs are constructed programatically. +// If you need to whitelist specific files, please use the 'whitelist' object. +var gExceptionPaths = [ + "resource://app/defaults/settings/blocklists/", + "resource://app/defaults/settings/security-state/", + "resource://app/defaults/settings/main/", + "resource://app/defaults/preferences/", + "resource://gre/modules/commonjs/", + "resource://gre/defaults/pref/", + + // These chrome resources are referenced using relative paths from JS files. + "chrome://global/content/certviewer/components/", + + // https://github.com/mozilla/activity-stream/issues/3053 + "chrome://activity-stream/content/data/content/tippytop/images/", + "chrome://activity-stream/content/data/content/tippytop/favicons/", + // These resources are referenced by messages delivered through Remote Settings + "chrome://activity-stream/content/data/content/assets/remote/", + "chrome://activity-stream/content/data/content/assets/mobile-download-qr-new-user-cn.svg", + "chrome://activity-stream/content/data/content/assets/mobile-download-qr-existing-user-cn.svg", + "chrome://activity-stream/content/data/content/assets/person-typing.svg", + "chrome://browser/content/assets/moz-vpn.svg", + "chrome://browser/content/assets/vpn-logo.svg", + "chrome://browser/content/assets/focus-promo.png", + "chrome://browser/content/assets/klar-qr-code.svg", + + // toolkit/components/pdfjs/content/build/pdf.js + "resource://pdf.js/web/images/", + + // Exclude the form autofill path that has been moved out of the extensions to + // toolkit, see bug 1691821. + "resource://gre-resources/autofill/", + + // Exclude all search-extensions because they aren't referenced by filename + "resource://search-extensions/", + + // Exclude all services-automation because they are used through webdriver + "resource://gre/modules/services-automation/", + "resource://services-automation/ServicesAutomation.jsm", + + // Paths from this folder are constructed in NetErrorParent.sys.mjs based on + // the type of cert or net error the user is encountering. + "chrome://global/content/neterror/supportpages/", + + // Points to theme preview images, which are defined in browser/ but only used + // in toolkit/mozapps/extensions/content/aboutaddons.js. + "resource://usercontext-content/builtin-themes/", + + // Page data schemas are referenced programmatically. + "chrome://browser/content/pagedata/schemas/", + + // Nimbus schemas are referenced programmatically. + "resource://nimbus/schemas/", + + // Activity stream schemas are referenced programmatically. + "resource://activity-stream/schemas", + + // Localization file added programatically in featureCallout.jsm + "resource://app/localization/en-US/browser/featureCallout.ftl", +]; + +// These are not part of the omni.ja file, so we find them only when running +// the test on a non-packaged build. +if (AppConstants.platform == "macosx") { + gExceptionPaths.push("resource://gre/res/cursors/"); + gExceptionPaths.push("resource://gre/res/touchbar/"); +} + +if (AppConstants.MOZ_BACKGROUNDTASKS) { + // These preferences are active only when we're in background task mode. + gExceptionPaths.push("resource://gre/defaults/backgroundtasks/"); + gExceptionPaths.push("resource://app/defaults/backgroundtasks/"); + // `BackgroundTask_id.jsm` is loaded at runtime by `app --backgroundtask id ...`. + gExceptionPaths.push("resource://gre/modules/backgroundtasks/"); + gExceptionPaths.push("resource://app/modules/backgroundtasks/"); +} + +// Bug 1710546 https://bugzilla.mozilla.org/show_bug.cgi?id=1710546 +if (AppConstants.NIGHTLY_BUILD) { + gExceptionPaths.push("resource://builtin-addons/translations/"); +} + +if (AppConstants.NIGHTLY_BUILD) { + // This is nightly-only debug tool. + gExceptionPaths.push( + "chrome://browser/content/places/interactionsViewer.html" + ); +} + +// Each whitelist entry should have a comment indicating which file is +// referencing the whitelisted file in a way that the test can't detect, or a +// bug number to remove or use the file if it is indeed currently unreferenced. +var whitelist = [ + // toolkit/components/pdfjs/content/PdfStreamConverter.jsm + { file: "chrome://pdf.js/locale/chrome.properties" }, + { file: "chrome://pdf.js/locale/viewer.properties" }, + + // security/manager/pki/resources/content/device_manager.js + { file: "chrome://pippki/content/load_device.xhtml" }, + + // The l10n build system can't package string files only for some platforms. + // See bug 1339424 for why this is hard to fix. + { + file: "chrome://global/locale/fallbackMenubar.properties", + platforms: ["linux", "win"], + }, + { + file: "resource://gre/localization/en-US/toolkit/printing/printDialogs.ftl", + platforms: ["linux", "macosx"], + }, + + // This file is referenced by the build system to generate the + // Firefox .desktop entry. See bug 1824327 (and perhaps bug 1526672) + { + file: "resource://app/localization/en-US/browser/linuxDesktopEntry.ftl", + }, + + // toolkit/content/aboutRights-unbranded.xhtml doesn't use aboutRights.css + { file: "chrome://global/skin/aboutRights.css", skipUnofficial: true }, + + // devtools/client/inspector/bin/dev-server.js + { + file: "chrome://devtools/content/inspector/markup/markup.xhtml", + isFromDevTools: true, + }, + + // used by devtools/client/memory/index.xhtml + { file: "chrome://global/content/third_party/d3/d3.js" }, + + // SpiderMonkey parser API, currently unused in browser/ and toolkit/ + { file: "resource://gre/modules/reflect.sys.mjs" }, + + // extensions/pref/autoconfig/src/nsReadConfig.cpp + { file: "resource://gre/defaults/autoconfig/prefcalls.js" }, + + // browser/components/preferences/moreFromMozilla.js + // These files URLs are constructed programatically at run time. + { + file: "chrome://browser/content/preferences/more-from-mozilla-qr-code-simple.svg", + }, + { + file: "chrome://browser/content/preferences/more-from-mozilla-qr-code-simple-cn.svg", + }, + + { file: "resource://gre/greprefs.js" }, + + // layout/mathml/nsMathMLChar.cpp + { file: "resource://gre/res/fonts/mathfontSTIXGeneral.properties" }, + { file: "resource://gre/res/fonts/mathfontUnicode.properties" }, + + // toolkit/mozapps/extensions/AddonContentPolicy.cpp + { file: "resource://gre/localization/en-US/toolkit/global/cspErrors.ftl" }, + + // The l10n build system can't package string files only for some platforms. + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/accessible.properties", + platforms: ["linux", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/intl.properties", + platforms: ["linux", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/platformKeys.properties", + platforms: ["linux", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/accessible.properties", + platforms: ["macosx", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/intl.properties", + platforms: ["macosx", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/platformKeys.properties", + platforms: ["macosx", "win"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/accessible.properties", + platforms: ["linux", "macosx"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/intl.properties", + platforms: ["linux", "macosx"], + }, + { + file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/platformKeys.properties", + platforms: ["linux", "macosx"], + }, + + // Files from upstream library + { file: "resource://pdf.js/web/debugger.js" }, + { file: "resource://pdf.js/web/debugger.css" }, + + // resource://app/modules/translation/TranslationContentHandler.jsm + { file: "resource://app/modules/translation/BingTranslator.jsm" }, + { file: "resource://app/modules/translation/GoogleTranslator.jsm" }, + { file: "resource://app/modules/translation/YandexTranslator.jsm" }, + + // Starting from here, files in the whitelist are bugs that need fixing. + // Bug 1339424 (wontfix?) + { + file: "chrome://browser/locale/taskbar.properties", + platforms: ["linux", "macosx"], + }, + // Bug 1344267 + { file: "chrome://remote/content/marionette/test_dialog.properties" }, + { file: "chrome://remote/content/marionette/test_dialog.xhtml" }, + { file: "chrome://remote/content/marionette/test_menupopup.xhtml" }, + { file: "chrome://remote/content/marionette/test_no_xul.xhtml" }, + { file: "chrome://remote/content/marionette/test.xhtml" }, + // Bug 1348559 + { file: "chrome://pippki/content/resetpassword.xhtml" }, + // Bug 1337345 + { file: "resource://gre/modules/Manifest.sys.mjs" }, + // Bug 1494170 + // (The references to these files are dynamically generated, so the test can't + // find the references) + { + file: "chrome://devtools/skin/images/aboutdebugging-firefox-aurora.svg", + isFromDevTools: true, + }, + { + file: "chrome://devtools/skin/images/aboutdebugging-firefox-beta.svg", + isFromDevTools: true, + }, + { + file: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg", + isFromDevTools: true, + }, + { file: "chrome://devtools/skin/images/next.svg", isFromDevTools: true }, + + // Bug 1526672 + { + file: "resource://app/localization/en-US/browser/touchbar/touchbar.ftl", + platforms: ["linux", "win"], + }, + // Referenced by the webcompat system addon for localization + { file: "resource://gre/localization/en-US/toolkit/about/aboutCompat.ftl" }, + + // dom/media/mediacontrol/MediaControlService.cpp + { file: "resource://gre/localization/en-US/dom/media.ftl" }, + + // dom/xml/nsXMLPrettyPrinter.cpp + { file: "resource://gre/localization/en-US/dom/XMLPrettyPrint.ftl" }, + + // tookit/mozapps/update/BackgroundUpdate.jsm + { + file: "resource://gre/localization/en-US/toolkit/updates/backgroundupdate.ftl", + }, + // Bug 1713242 - referenced by aboutThirdParty.html which is only for Windows + { + file: "resource://gre/localization/en-US/toolkit/about/aboutThirdParty.ftl", + platforms: ["linux", "macosx"], + }, + // Bug 1973834 - referenced by aboutWindowsMessages.html which is only for Windows + { + file: "resource://gre/localization/en-US/toolkit/about/aboutWindowsMessages.ftl", + platforms: ["linux", "macosx"], + }, + // Bug 1721741: + // (The references to these files are dynamically generated, so the test can't + // find the references) + { file: "chrome://browser/content/screenshots/copied-notification.svg" }, + + // toolkit/xre/MacRunFromDmgUtils.mm + { file: "resource://gre/localization/en-US/toolkit/global/run-from-dmg.ftl" }, + + // Referenced by screenshots extension + { file: "chrome://browser/content/screenshots/cancel.svg" }, + { file: "chrome://browser/content/screenshots/copy.svg" }, + { file: "chrome://browser/content/screenshots/download.svg" }, + { file: "chrome://browser/content/screenshots/download-white.svg" }, + + // Bug 1824826 - Implement a view of history in Firefox View + { file: "resource://gre/modules/PlacesQuery.sys.mjs" }, + + // Should be removed in bug 1824826 when fxview-tab-list is used in Firefox View + { file: "resource://app/localization/en-US/browser/fxviewTabList.ftl" }, + { file: "chrome://browser/content/firefoxview/fxview-tab-list.css" }, + { file: "chrome://browser/content/firefoxview/fxview-tab-list.mjs" }, + { file: "chrome://browser/content/firefoxview/fxview-tab-row.css" }, + + // Bug 1834176 - Imports of NetUtil can't be converted until hostutils is + // updated. + { file: "resource://gre/modules/NetUtil.sys.mjs" }, +]; + +if (AppConstants.NIGHTLY_BUILD && AppConstants.platform != "win") { + // This path is refereneced in nsFxrCommandLineHandler.cpp, which is only + // compiled in Windows. Whitelisted this path so that non-Windows builds + // can access the FxR UI via --chrome rather than --fxr (which includes VR- + // specific functionality) + whitelist.push({ file: "chrome://fxr/content/fxrui.html" }); +} + +if (AppConstants.platform == "android") { + // The l10n build system can't package string files only for some platforms. + // Referenced by aboutGlean.html + whitelist.push({ + file: "resource://gre/localization/en-US/toolkit/about/aboutGlean.ftl", + }); +} + +if (AppConstants.MOZ_UPDATE_AGENT && !AppConstants.MOZ_BACKGROUNDTASKS) { + // Task scheduling is only used for background updates right now. + whitelist.push({ + file: "resource://gre/modules/TaskScheduler.jsm", + }); +} + +whitelist = new Set( + whitelist + .filter( + item => + "isFromDevTools" in item == isDevtools && + (!item.skipUnofficial || !AppConstants.MOZILLA_OFFICIAL) && + (!item.platforms || item.platforms.includes(AppConstants.platform)) + ) + .map(item => item.file) +); + +const ignorableWhitelist = new Set([ + // The following files are outside of the omni.ja file, so we only catch them + // when testing on a non-packaged build. + + // toolkit/mozapps/extensions/nsBlocklistService.js + "resource://app/blocklist.xml", + + // dom/media/gmp/GMPParent.cpp + "resource://gre/gmp-clearkey/0.1/manifest.json", + + // Bug 1351669 - obsolete test file + "resource://gre/res/test.properties", +]); +for (let entry of ignorableWhitelist) { + whitelist.add(entry); +} + +if (!isDevtools) { + // services/sync/modules/service.sys.mjs + for (let module of [ + "addons.sys.mjs", + "bookmarks.sys.mjs", + "forms.sys.mjs", + "history.sys.mjs", + "passwords.sys.mjs", + "prefs.sys.mjs", + "tabs.sys.mjs", + "extension-storage.sys.mjs", + ]) { + whitelist.add("resource://services-sync/engines/" + module); + } + // resource://devtools/shared/worker/loader.js, + // resource://devtools/shared/loader/builtin-modules.js + if (!AppConstants.ENABLE_WEBDRIVER) { + whitelist.add("resource://gre/modules/jsdebugger.sys.mjs"); + } +} + +if (AppConstants.MOZ_CODE_COVERAGE) { + whitelist.add( + "chrome://remote/content/marionette/PerTestCoverageUtils.sys.mjs" + ); +} + +const gInterestingCategories = new Set([ + "agent-style-sheets", + "addon-provider-module", + "webextension-modules", + "webextension-scripts", + "webextension-schemas", + "webextension-scripts-addon", + "webextension-scripts-content", + "webextension-scripts-devtools", +]); + +var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry +); +var gChromeMap = new Map(); +var gOverrideMap = new Map(); +var gComponentsSet = new Set(); + +// In this map when the value is a Set of URLs, the file is referenced if any +// of the files in the Set is referenced. +// When the value is null, the file is referenced unconditionally. +// When the value is a string, "whitelist-direct" means that we have not found +// any reference in the code, but have a matching whitelist entry for this file. +// "whitelist" means that the file is indirectly whitelisted, ie. a whitelisted +// file causes this file to be referenced. +var gReferencesFromCode = new Map(); + +var resHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); +var gResourceMap = []; +function trackResourcePrefix(prefix) { + let uri = Services.io.newURI("resource://" + prefix + "/"); + gResourceMap.unshift([prefix, resHandler.resolveURI(uri)]); +} +trackResourcePrefix("gre"); +trackResourcePrefix("app"); + +function getBaseUriForChromeUri(chromeUri) { + let chromeFile = chromeUri + "nonexistentfile.reallynothere"; + let uri = Services.io.newURI(chromeFile); + let fileUri = gChromeReg.convertChromeURL(uri); + return fileUri.resolve("."); +} + +function trackChromeUri(uri) { + gChromeMap.set(getBaseUriForChromeUri(uri), uri); +} + +// formautofill registers resource://formautofill/ and +// chrome://formautofill/content/ dynamically at runtime. +// Bug 1480276 is about addressing this without this hard-coding. +trackResourcePrefix("autofill"); +trackChromeUri("chrome://formautofill/content/"); + +function parseManifest(manifestUri) { + return fetchFile(manifestUri.spec).then(data => { + for (let line of data.split("\n")) { + let [type, ...argv] = line.split(/\s+/); + if (type == "content" || type == "skin" || type == "locale") { + let chromeUri = `chrome://${argv[0]}/${type}/`; + // The webcompat reporter's locale directory may not exist if + // the addon is preffed-off, and since it's a hack until we + // get bz1425104 landed, we'll just skip it for now. + if (chromeUri === "chrome://report-site-issue/locale/") { + gChromeMap.set("chrome://report-site-issue/locale/", true); + } else { + trackChromeUri(chromeUri); + } + } else if (type == "override" || type == "overlay") { + // Overlays aren't really overrides, but behave the same in + // that the overlay is only referenced if the original xul + // file is referenced somewhere. + let os = "os=" + Services.appinfo.OS; + if (!argv.some(s => s.startsWith("os=") && s != os)) { + gOverrideMap.set( + Services.io.newURI(argv[1]).specIgnoringRef, + Services.io.newURI(argv[0]).specIgnoringRef + ); + } + } else if (type == "category" && gInterestingCategories.has(argv[0])) { + gReferencesFromCode.set(argv[2], null); + } else if (type == "resource") { + trackResourcePrefix(argv[0]); + } else if (type == "component") { + gComponentsSet.add(argv[1]); + } + } + }); +} + +// If the given URI is a webextension manifest, extract files used by +// any of its APIs (scripts, icons, style sheets, theme images). +// Returns the passed in URI if the manifest is not a webextension +// manifest, null otherwise. +async function parseJsonManifest(uri) { + uri = Services.io.newURI(convertToCodeURI(uri.spec)); + + let raw = await fetchFile(uri.spec); + let data; + try { + data = JSON.parse(raw); + } catch (ex) { + return uri; + } + + // Simplistic test for whether this is a webextension manifest: + if (data.manifest_version !== 2) { + return uri; + } + + if (data.background?.scripts) { + for (let bgscript of data.background.scripts) { + gReferencesFromCode.set(uri.resolve(bgscript), null); + } + } + + if (data.icons) { + for (let icon of Object.values(data.icons)) { + gReferencesFromCode.set(uri.resolve(icon), null); + } + } + + if (data.experiment_apis) { + for (let api of Object.values(data.experiment_apis)) { + if (api.parent && api.parent.script) { + let script = uri.resolve(api.parent.script); + gReferencesFromCode.set(script, null); + } + + if (api.schema) { + gReferencesFromCode.set(uri.resolve(api.schema), null); + } + } + } + + if (data.theme_experiment && data.theme_experiment.stylesheet) { + let stylesheet = uri.resolve(data.theme_experiment.stylesheet); + gReferencesFromCode.set(stylesheet, null); + } + + for (let themeKey of ["theme", "dark_theme"]) { + if (data?.[themeKey]?.images?.additional_backgrounds) { + for (let background of data[themeKey].images.additional_backgrounds) { + gReferencesFromCode.set(uri.resolve(background), null); + } + } + } + + return null; +} + +function addCodeReference(url, fromURI) { + let from = convertToCodeURI(fromURI.spec); + + // Ignore self references. + if (url == from) { + return; + } + + let ref; + if (gReferencesFromCode.has(url)) { + ref = gReferencesFromCode.get(url); + if (ref === null) { + return; + } + } else { + ref = new Set(); + gReferencesFromCode.set(url, ref); + } + ref.add(from); +} + +function listCodeReferences(refs) { + let refList = []; + if (refs) { + for (let ref of refs) { + refList.push(ref); + } + } + return refList.join(","); +} + +function parseCSSFile(fileUri) { + return fetchFile(fileUri.spec).then(data => { + for (let line of data.split("\n")) { + let urls = line.match(/url\([^()]+\)/g); + if (!urls) { + // @import rules can take a string instead of a url. + let importMatch = line.match(/@import ['"]?([^'"]*)['"]?/); + if (importMatch && importMatch[1]) { + let url = Services.io.newURI(importMatch[1], null, fileUri).spec; + addCodeReference(convertToCodeURI(url), fileUri); + } + continue; + } + + for (let url of urls) { + // Remove the url(" prefix and the ") suffix. + url = url + .replace(/url\(([^)]*)\)/, "$1") + .replace(/^"(.*)"$/, "$1") + .replace(/^'(.*)'$/, "$1"); + if (url.startsWith("data:")) { + continue; + } + + try { + url = Services.io.newURI(url, null, fileUri).specIgnoringRef; + addCodeReference(convertToCodeURI(url), fileUri); + } catch (e) { + ok(false, "unexpected error while resolving this URI: " + url); + } + } + } + }); +} + +function parseCodeFile(fileUri) { + return fetchFile(fileUri.spec).then(data => { + let baseUri; + for (let line of data.split("\n")) { + let urls = line.match( + /["'`]chrome:\/\/[a-zA-Z0-9-]+\/(content|skin|locale)\/[^"'` ]*["'`]/g + ); + + if (!urls) { + urls = line.match(/["']resource:\/\/[^"']+["']/g); + if ( + urls && + isDevtools && + /baseURI: "resource:\/\/devtools\//.test(line) + ) { + baseUri = Services.io.newURI(urls[0].slice(1, -1)); + continue; + } + } + + if (!urls) { + urls = line.match(/[a-z0-9_\/-]+\.ftl/i); + if (urls) { + urls = urls[0]; + let grePrefix = Services.io.newURI( + "resource://gre/localization/en-US/" + ); + let appPrefix = Services.io.newURI( + "resource://app/localization/en-US/" + ); + + let grePrefixUrl = Services.io.newURI(urls, null, grePrefix).spec; + let appPrefixUrl = Services.io.newURI(urls, null, appPrefix).spec; + + addCodeReference(grePrefixUrl, fileUri); + addCodeReference(appPrefixUrl, fileUri); + continue; + } + } + + if (!urls) { + // If there's no absolute chrome URL, look for relative ones in + // src and href attributes. + let match = line.match("(?:src|href)=[\"']([^$&\"']+)"); + if (match && match[1]) { + let url = Services.io.newURI(match[1], null, fileUri).spec; + addCodeReference(convertToCodeURI(url), fileUri); + } + + // This handles `import` lines which may be multi-line. + // We have an ESLint rule, `import/no-unassigned-import` which prevents + // using bare `import "foo.js"`, so we don't need to handle that case + // here. + match = line.match(/from\W*['"](.*?)['"]/); + if (match?.[1]) { + let url = match[1]; + url = Services.io.newURI(url, null, baseUri || fileUri).spec; + url = convertToCodeURI(url); + addCodeReference(url, fileUri); + } + + if (isDevtools) { + let rules = [ + ["devtools/client/locales", "chrome://devtools/locale"], + ["devtools/shared/locales", "chrome://devtools-shared/locale"], + [ + "devtools/shared/platform", + "resource://devtools/shared/platform/chrome", + ], + ["devtools", "resource://devtools"], + ]; + + match = line.match(/["']((?:devtools)\/[^\\#"']+)["']/); + if (match && match[1]) { + let path = match[1]; + for (let rule of rules) { + if (path.startsWith(rule[0] + "/")) { + path = path.replace(rule[0], rule[1]); + if (!/\.(properties|js|jsm|mjs|json|css)$/.test(path)) { + path += ".js"; + } + addCodeReference(path, fileUri); + break; + } + } + } + + match = line.match(/require\(['"](\.[^'"]+)['"]\)/); + if (match && match[1]) { + let url = match[1]; + url = Services.io.newURI(url, null, baseUri || fileUri).spec; + url = convertToCodeURI(url); + if (!/\.(properties|js|jsm|mjs|json|css)$/.test(url)) { + url += ".js"; + } + if (url.startsWith("resource://")) { + addCodeReference(url, fileUri); + } else { + // if we end up with a chrome:// url here, it's likely because + // a baseURI to a resource:// path has been defined in another + // .js file that is loaded in the same scope, we can't detect it. + } + } + } + continue; + } + + for (let url of urls) { + // Remove quotes. + url = url.slice(1, -1); + // Remove ? or \ trailing characters. + if (url.endsWith("\\")) { + url = url.slice(0, -1); + } + + let pos = url.indexOf("?"); + if (pos != -1) { + url = url.slice(0, pos); + } + + // Make urls like chrome://browser/skin/ point to an actual file, + // and remove the ref if any. + try { + url = Services.io.newURI(url).specIgnoringRef; + } catch (e) { + continue; + } + + if ( + isDevtools && + line.includes("require(") && + !/\.(properties|js|jsm|mjs|json|css)$/.test(url) + ) { + url += ".js"; + } + + addCodeReference(url, fileUri); + } + } + }); +} + +function convertToCodeURI(fileUri) { + let baseUri = fileUri; + let path = ""; + while (true) { + let slashPos = baseUri.lastIndexOf("/", baseUri.length - 2); + if (slashPos <= 0) { + // File not accessible from chrome protocol, try resource:// + for (let res of gResourceMap) { + if (fileUri.startsWith(res[1])) { + return fileUri.replace(res[1], "resource://" + res[0] + "/"); + } + } + // Give up and return the original URL. + return fileUri; + } + path = baseUri.slice(slashPos + 1) + path; + baseUri = baseUri.slice(0, slashPos + 1); + if (gChromeMap.has(baseUri)) { + return gChromeMap.get(baseUri) + path; + } + } +} + +async function chromeFileExists(aURI) { + try { + return await PerfTestHelpers.checkURIExists(aURI); + } catch (e) { + todo(false, `Failed to check if ${aURI} exists: ${e}`); + return false; + } +} + +function findChromeUrlsFromArray(array, prefix) { + // Find the first character of the prefix... + for ( + let index = 0; + (index = array.indexOf(prefix.charCodeAt(0), index)) != -1; + ++index + ) { + // Then ensure we actually have the whole prefix. + let found = true; + for (let i = 1; i < prefix.length; ++i) { + if (array[index + i] != prefix.charCodeAt(i)) { + found = false; + break; + } + } + if (!found) { + continue; + } + + // C strings are null terminated, but " also terminates urls + // (nsIndexedToHTML.cpp contains an HTML fragment with several chrome urls) + // Let's also terminate the string on the # character to skip references. + let end = Math.min( + array.indexOf(0, index), + array.indexOf('"'.charCodeAt(0), index), + array.indexOf(")".charCodeAt(0), index), + array.indexOf("#".charCodeAt(0), index) + ); + let string = ""; + for (; index < end; ++index) { + string += String.fromCharCode(array[index]); + } + + // Only keep strings that look like real chrome or resource urls. + if ( + /chrome:\/\/[a-zA-Z09-]+\/(content|skin|locale)\//.test(string) || + /resource:\/\/[a-zA-Z09-]*\/.*\.[a-z]+/.test(string) + ) { + gReferencesFromCode.set(string, null); + } + } +} + +add_task(async function checkAllTheFiles() { + TestUtils.assertPackagedBuild(); + + const libxul = await IOUtils.read(PathUtils.xulLibraryPath); + findChromeUrlsFromArray(libxul, "chrome://"); + findChromeUrlsFromArray(libxul, "resource://"); + // Handle NS_LITERAL_STRING. + let uint16 = new Uint16Array(libxul.buffer); + findChromeUrlsFromArray(uint16, "chrome://"); + findChromeUrlsFromArray(uint16, "resource://"); + + const kCodeExtensions = [ + ".xml", + ".xsl", + ".mjs", + ".js", + ".jsm", + ".json", + ".html", + ".xhtml", + ]; + + let appDir = Services.dirsvc.get("GreD", Ci.nsIFile); + // This asynchronously produces a list of URLs (sadly, mostly sync on our + // test infrastructure because it runs against jarfiles there, and + // our zipreader APIs are all sync) + let uris = await generateURIsFromDirTree( + appDir, + [ + ".css", + ".manifest", + ".jpg", + ".png", + ".gif", + ".svg", + ".ftl", + ".dtd", + ".properties", + ].concat(kCodeExtensions) + ); + + // Parse and remove all manifests from the list. + // NOTE that this must be done before filtering out devtools paths + // so that all chrome paths can be recorded. + let manifestURIs = []; + let jsonManifests = []; + uris = uris.filter(uri => { + let path = uri.pathQueryRef; + if (path.endsWith(".manifest")) { + manifestURIs.push(uri); + return false; + } else if (path.endsWith("/manifest.json")) { + jsonManifests.push(uri); + return false; + } + + return true; + }); + + // Wait for all manifest to be parsed + await PerfTestHelpers.throttledMapPromises(manifestURIs, parseManifest); + + for (let jsm of Components.manager.getComponentJSMs()) { + gReferencesFromCode.set(jsm, null); + } + for (let esModule of Components.manager.getComponentESModules()) { + gReferencesFromCode.set(esModule, null); + } + + // manifest.json is a common name, it is used for WebExtension manifests + // but also for other things. To tell them apart, we have to actually + // read the contents. This will populate gExtensionRoots with all + // embedded extension APIs, and return any manifest.json files that aren't + // webextensions. + let nonWebextManifests = ( + await Promise.all(jsonManifests.map(parseJsonManifest)) + ).filter(uri => !!uri); + uris.push(...nonWebextManifests); + + // We build a list of promises that get resolved when their respective + // files have loaded and produced no errors. + let allPromises = []; + + for (let uri of uris) { + let path = uri.pathQueryRef; + if (path.endsWith(".css")) { + allPromises.push([parseCSSFile, uri]); + } else if (kCodeExtensions.some(ext => path.endsWith(ext))) { + allPromises.push([parseCodeFile, uri]); + } + } + + // Wait for all the files to have actually loaded: + await PerfTestHelpers.throttledMapPromises(allPromises, ([task, uri]) => + task(uri) + ); + + // Keep only chrome:// files, and filter out either the devtools paths or + // the non-devtools paths: + let devtoolsPrefixes = [ + "chrome://devtools", + "resource://devtools/", + "resource://devtools-client-jsonview/", + "resource://devtools-client-shared/", + "resource://app/modules/devtools", + "resource://gre/modules/devtools", + "resource://app/localization/en-US/startup/aboutDevTools.ftl", + "resource://app/localization/en-US/devtools/", + ]; + let hasDevtoolsPrefix = uri => + devtoolsPrefixes.some(prefix => uri.startsWith(prefix)); + let chromeFiles = []; + for (let uri of uris) { + uri = convertToCodeURI(uri.spec); + if ( + (uri.startsWith("chrome://") || uri.startsWith("resource://")) && + isDevtools == hasDevtoolsPrefix(uri) + ) { + chromeFiles.push(uri); + } + } + + if (isDevtools) { + // chrome://devtools/skin/devtools-browser.css is included from browser.xhtml + gReferencesFromCode.set(AppConstants.BROWSER_CHROME_URL, null); + // devtools' css is currently included from browser.css, see bug 1204810. + gReferencesFromCode.set("chrome://browser/skin/browser.css", null); + } + + let isUnreferenced = file => { + if (gExceptionPaths.some(e => file.startsWith(e))) { + return false; + } + if (gReferencesFromCode.has(file)) { + let refs = gReferencesFromCode.get(file); + if (refs === null) { + return false; + } + for (let ref of refs) { + if (isDevtools) { + if ( + ref.startsWith("resource://app/components/") || + (file.startsWith("chrome://") && ref.startsWith("resource://")) + ) { + return false; + } + } + + if (gReferencesFromCode.has(ref)) { + let refType = gReferencesFromCode.get(ref); + if ( + refType === null || // unconditionally referenced + refType == "whitelist" || + refType == "whitelist-direct" + ) { + return false; + } + } + } + } + return !gOverrideMap.has(file) || isUnreferenced(gOverrideMap.get(file)); + }; + + let unreferencedFiles = chromeFiles; + + let removeReferenced = useWhitelist => { + let foundReference = false; + unreferencedFiles = unreferencedFiles.filter(f => { + let rv = isUnreferenced(f); + if (rv && f.startsWith("resource://app/")) { + rv = isUnreferenced(f.replace("resource://app/", "resource:///")); + } + if (rv && /^resource:\/\/(?:app|gre)\/components\/[^/]+\.js$/.test(f)) { + rv = !gComponentsSet.has(f.replace(/.*\//, "")); + } + if (!rv) { + foundReference = true; + if (useWhitelist) { + info( + "indirectly whitelisted file: " + + f + + " used from " + + listCodeReferences(gReferencesFromCode.get(f)) + ); + } + gReferencesFromCode.set(f, useWhitelist ? "whitelist" : null); + } + return rv; + }); + return foundReference; + }; + // First filter out the files that are referenced. + while (removeReferenced(false)) { + // As long as removeReferenced returns true, some files have been marked + // as referenced, so we need to run it again. + } + // Marked as referenced the files that have been explicitly whitelisted. + unreferencedFiles = unreferencedFiles.filter(file => { + if (whitelist.has(file)) { + whitelist.delete(file); + gReferencesFromCode.set(file, "whitelist-direct"); + return false; + } + return true; + }); + // Run the process again, this time when more files are marked as referenced, + // it's a consequence of the whitelist. + while (removeReferenced(true)) { + // As long as removeReferenced returns true, we need to run it again. + } + + unreferencedFiles.sort(); + + if (isDevtools) { + // Bug 1351878 - handle devtools resource files + unreferencedFiles = unreferencedFiles.filter(file => { + if (file.startsWith("resource://")) { + info("unreferenced devtools resource file: " + file); + return false; + } + return true; + }); + } + + is(unreferencedFiles.length, 0, "there should be no unreferenced files"); + for (let file of unreferencedFiles) { + let refs = gReferencesFromCode.get(file); + if (refs === undefined) { + ok(false, "unreferenced file: " + file); + } else { + let refList = listCodeReferences(refs); + let msg = "file only referenced from unreferenced files: " + file; + if (refList) { + msg += " referenced from " + refList; + } + ok(false, msg); + } + } + + for (let file of whitelist) { + if (ignorableWhitelist.has(file)) { + info("ignored unused whitelist entry: " + file); + } else { + ok(false, "unused whitelist entry: " + file); + } + } + + for (let [file, refs] of gReferencesFromCode) { + if ( + isDevtools != devtoolsPrefixes.some(prefix => file.startsWith(prefix)) + ) { + continue; + } + + if ( + (file.startsWith("chrome://") || file.startsWith("resource://")) && + !(await chromeFileExists(file)) + ) { + // Ignore chrome prefixes that have been automatically expanded. + let pathParts = + file.match("chrome://([^/]+)/content/([^/.]+).xul") || + file.match("chrome://([^/]+)/skin/([^/.]+).css"); + if (pathParts && pathParts[1] == pathParts[2]) { + continue; + } + + // TODO: bug 1349010 - add a whitelist and make this reliable enough + // that we could make the test fail when this catches something new. + let refList = listCodeReferences(refs); + let msg = "missing file: " + file; + if (refList) { + msg += " referenced from " + refList; + } + info(msg); + } + } +}); diff --git a/browser/base/content/test/static/browser_misused_characters_in_strings.js b/browser/base/content/test/static/browser_misused_characters_in_strings.js new file mode 100644 index 0000000000..42be3b4392 --- /dev/null +++ b/browser/base/content/test/static/browser_misused_characters_in_strings.js @@ -0,0 +1,276 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This list allows pre-existing or 'unfixable' issues to remain, while we + * detect newly occurring issues in shipping files. It is a list of objects + * specifying conditions under which an error should be ignored. + * + * As each issue is found in the exceptions list, it is removed from the list. + * At the end of the test, there is an assertion that all items have been + * removed from the exceptions list, thus ensuring there are no stale + * entries. */ +let gExceptionsList = [ + { + file: "layout_errors.properties", + key: "ImageMapRectBoundsError", + type: "double-quote", + }, + { + file: "layout_errors.properties", + key: "ImageMapCircleWrongNumberOfCoords", + type: "double-quote", + }, + { + file: "layout_errors.properties", + key: "ImageMapCircleNegativeRadius", + type: "double-quote", + }, + { + file: "layout_errors.properties", + key: "ImageMapPolyWrongNumberOfCoords", + type: "double-quote", + }, + { + file: "layout_errors.properties", + key: "ImageMapPolyOddNumberOfCoords", + type: "double-quote", + }, + { + file: "dom.properties", + key: "PatternAttributeCompileFailure", + type: "single-quote", + }, + // dom.properties is packaged twice so we need to have two exceptions for this string. + { + file: "dom.properties", + key: "PatternAttributeCompileFailure", + type: "single-quote", + }, + { + file: "dom.properties", + key: "ImportMapExternalNotSupported", + type: "single-quote", + }, + // dom.properties is packaged twice so we need to have two exceptions for this string. + { + file: "dom.properties", + key: "ImportMapExternalNotSupported", + type: "single-quote", + }, +]; + +/** + * Check if an error should be ignored due to matching one of the exceptions + * defined in gExceptionsList. + * + * @param filepath The URI spec of the locale file + * @param key The key of the entity that is being checked + * @param type The type of error that has been found + * @return true if the error should be ignored, false otherwise. + */ +function ignoredError(filepath, key, type) { + for (let index in gExceptionsList) { + let exceptionItem = gExceptionsList[index]; + if ( + filepath.endsWith(exceptionItem.file) && + key == exceptionItem.key && + type == exceptionItem.type + ) { + gExceptionsList.splice(index, 1); + return true; + } + } + return false; +} + +function testForError(filepath, key, str, pattern, type, helpText) { + if (str.match(pattern) && !ignoredError(filepath, key, type)) { + ok(false, `${filepath} with key=${key} has a misused ${type}. ${helpText}`); + } +} + +function testForErrors(filepath, key, str) { + testForError( + filepath, + key, + str, + /(\w|^)'\w/, + "apostrophe", + "Strings with apostrophes should use foo\u2019s instead of foo's." + ); + testForError( + filepath, + key, + str, + /\w\u2018\w/, + "incorrect-apostrophe", + "Strings with apostrophes should use foo\u2019s instead of foo\u2018s." + ); + testForError( + filepath, + key, + str, + /'.+'/, + "single-quote", + "Single-quoted strings should use Unicode \u2018foo\u2019 instead of 'foo'." + ); + testForError( + filepath, + key, + str, + /"/, + "double-quote", + 'Double-quoted strings should use Unicode \u201cfoo\u201d instead of "foo".' + ); + testForError( + filepath, + key, + str, + /\.\.\./, + "ellipsis", + "Strings with an ellipsis should use the Unicode \u2026 character instead of three periods." + ); +} + +async function getAllTheFiles(extension) { + let appDirGreD = Services.dirsvc.get("GreD", Ci.nsIFile); + let appDirXCurProcD = Services.dirsvc.get("XCurProcD", Ci.nsIFile); + if (appDirGreD.contains(appDirXCurProcD)) { + return generateURIsFromDirTree(appDirGreD, [extension]); + } + if (appDirXCurProcD.contains(appDirGreD)) { + return generateURIsFromDirTree(appDirXCurProcD, [extension]); + } + let urisGreD = await generateURIsFromDirTree(appDirGreD, [extension]); + let urisXCurProcD = await generateURIsFromDirTree(appDirXCurProcD, [ + extension, + ]); + return Array.from(new Set(urisGreD.concat(urisXCurProcD))); +} + +add_task(async function checkAllTheProperties() { + // This asynchronously produces a list of URLs (sadly, mostly sync on our + // test infrastructure because it runs against jarfiles there, and + // our zipreader APIs are all sync) + let uris = await getAllTheFiles(".properties"); + ok( + uris.length, + `Found ${uris.length} .properties files to scan for misused characters` + ); + + for (let uri of uris) { + let bundle = Services.strings.createBundle(uri.spec); + + for (let entity of bundle.getSimpleEnumeration()) { + testForErrors(uri.spec, entity.key, entity.value); + } + } +}); + +var checkDTD = async function (aURISpec) { + let rawContents = await fetchFile(aURISpec); + // The regular expression below is adapted from: + // https://hg.mozilla.org/mozilla-central/file/68c0b7d6f16ce5bb023e08050102b5f2fe4aacd8/python/compare-locales/compare_locales/parser.py#l233 + let entities = rawContents.match( + /<!ENTITY\s+([\w\.]*)\s+("[^"]*"|'[^']*')\s*>/g + ); + if (!entities) { + // Some files have no entities defined. + return; + } + for (let entity of entities) { + let [, key, str] = entity.match( + /<!ENTITY\s+([\w\.]*)\s+("[^"]*"|'[^']*')\s*>/ + ); + // The matched string includes the enclosing quotation marks, + // we need to slice them off. + str = str.slice(1, -1); + testForErrors(aURISpec, key, str); + } +}; + +add_task(async function checkAllTheDTDs() { + let uris = await getAllTheFiles(".dtd"); + ok( + uris.length, + `Found ${uris.length} .dtd files to scan for misused characters` + ); + for (let uri of uris) { + await checkDTD(uri.spec); + } + + // This support DTD file supplies a string with a newline to make sure + // the regex in checkDTD works correctly for that case. + let dtdLocation = gTestPath.replace( + /\/[^\/]*$/i, + "/bug1262648_string_with_newlines.dtd" + ); + await checkDTD(dtdLocation); +}); + +add_task(async function checkAllTheFluents() { + let uris = await getAllTheFiles(".ftl"); + let { FluentParser, Visitor } = ChromeUtils.import( + "resource://testing-common/FluentSyntax.jsm" + ); + + class TextElementVisitor extends Visitor { + constructor() { + super(); + let domParser = new DOMParser(); + domParser.forceEnableDTD(); + + this.domParser = domParser; + this.uri = null; + this.id = null; + this.attr = null; + } + + visitMessage(node) { + this.id = node.id.name; + this.attr = null; + this.genericVisit(node); + } + + visitTerm(node) { + this.id = node.id.name; + this.attr = null; + this.genericVisit(node); + } + + visitAttribute(node) { + this.attr = node.id.name; + this.genericVisit(node); + } + + get key() { + if (this.attr) { + return `${this.id}.${this.attr}`; + } + return this.id; + } + + visitTextElement(node) { + const stripped_val = this.domParser.parseFromString( + "<!DOCTYPE html>" + node.value, + "text/html" + ).documentElement.textContent; + testForErrors(this.uri, this.key, stripped_val); + } + } + + const ftlParser = new FluentParser({ withSpans: false }); + const visitor = new TextElementVisitor(); + + for (let uri of uris) { + let rawContents = await fetchFile(uri.spec); + let ast = ftlParser.parse(rawContents); + + visitor.uri = uri.spec; + visitor.visit(ast); + } +}); + +add_task(async function ensureExceptionsListIsEmpty() { + is(gExceptionsList.length, 0, "No remaining exceptions exist"); +}); diff --git a/browser/base/content/test/static/browser_parsable_css.js b/browser/base/content/test/static/browser_parsable_css.js new file mode 100644 index 0000000000..6ff480fddc --- /dev/null +++ b/browser/base/content/test/static/browser_parsable_css.js @@ -0,0 +1,590 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This list allows pre-existing or 'unfixable' CSS issues to remain, while we + * detect newly occurring issues in shipping CSS. It is a list of objects + * specifying conditions under which an error should be ignored. + * + * Every property of the objects in it needs to consist of a regular expression + * matching the offending error. If an object has multiple regex criteria, they + * ALL need to match an error in order for that error not to cause a test + * failure. */ +let whitelist = [ + // CodeMirror is imported as-is, see bug 1004423. + { sourceName: /codemirror\.css$/i, isFromDevTools: true }, + { + sourceName: /devtools\/content\/debugger\/src\/components\/([A-z\/]+).css/i, + isFromDevTools: true, + }, + // Highlighter CSS uses a UA-only pseudo-class, see bug 985597. + { + sourceName: /highlighters\.css$/i, + errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i, + isFromDevTools: true, + }, + // UA-only media features. + { + sourceName: /\b(autocomplete-item)\.css$/, + errorMessage: /Expected media feature name but found \u2018-moz.*/i, + isFromDevTools: false, + platforms: ["windows"], + }, + { + sourceName: + /\b(contenteditable|EditorOverride|svg|forms|html|mathml|ua)\.css$/i, + errorMessage: /Unknown pseudo-class.*-moz-/i, + isFromDevTools: false, + }, + { + sourceName: + /\b(scrollbars|xul|html|mathml|ua|forms|svg|manageDialog|autocomplete-item-shared|formautofill)\.css$/i, + errorMessage: /Unknown property.*-moz-/i, + isFromDevTools: false, + }, + { + sourceName: /(scrollbars|xul)\.css$/i, + errorMessage: /Unknown pseudo-class.*-moz-/i, + isFromDevTools: false, + }, + // Reserved to UA sheets unless layout.css.overflow-clip-box.enabled flipped to true. + { + sourceName: /(?:res|gre-resources)\/forms\.css$/i, + errorMessage: /Unknown property.*overflow-clip-box/i, + isFromDevTools: false, + }, + // These variables are declared somewhere else, and error when we load the + // files directly. They're all marked intermittent because their appearance + // in the error console seems to not be consistent. + { + sourceName: /jsonview\/css\/general\.css$/i, + intermittent: true, + errorMessage: /Property contained reference to invalid variable.*color/i, + isFromDevTools: true, + }, + // PDF.js uses a property that is currently only supported in chrome. + { + sourceName: /web\/viewer\.css$/i, + errorMessage: + /Unknown property ātext-size-adjustā\. {2}Declaration dropped\./i, + isFromDevTools: false, + }, + { + sourceName: /overlay\.css$/i, + errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i, + isFromDevTools: false, + }, +]; + +if (!Services.prefs.getBoolPref("layout.css.color-mix.enabled")) { + // Reserved to UA sheets unless layout.css.color-mix.enabled flipped to true. + whitelist.push({ + sourceName: /\b(autocomplete-item)\.css$/, + errorMessage: /Expected color but found \u2018color-mix\u2019./i, + isFromDevTools: false, + platforms: ["windows"], + }); +} + +if (!Services.prefs.getBoolPref("layout.css.math-depth.enabled")) { + // mathml.css UA sheet rule for math-depth. + whitelist.push({ + sourceName: /\b(scrollbars|mathml)\.css$/i, + errorMessage: /Unknown property .*\bmath-depth\b/i, + isFromDevTools: false, + }); +} + +if (!Services.prefs.getBoolPref("layout.css.math-style.enabled")) { + // mathml.css UA sheet rule for math-style. + whitelist.push({ + sourceName: /(?:res|gre-resources)\/mathml\.css$/i, + errorMessage: /Unknown property .*\bmath-style\b/i, + isFromDevTools: false, + }); +} + +if (!Services.prefs.getBoolPref("layout.css.scroll-anchoring.enabled")) { + whitelist.push({ + sourceName: /webconsole\.css$/i, + errorMessage: /Unknown property .*\boverflow-anchor\b/i, + isFromDevTools: true, + }); +} + +if (!Services.prefs.getBoolPref("layout.css.forced-colors.enabled")) { + whitelist.push({ + sourceName: /pdf\.js\/web\/viewer\.css$/, + errorMessage: /Expected media feature name but found āforced-colorsā*/i, + isFromDevTools: false, + }); +} + +if (!Services.prefs.getBoolPref("layout.css.forced-color-adjust.enabled")) { + // PDF.js uses a property that is currently not enabled. + whitelist.push({ + sourceName: /web\/viewer\.css$/i, + errorMessage: + /Unknown property āforced-color-adjustā\. {2}Declaration dropped\./i, + isFromDevTools: false, + }); +} + +let propNameWhitelist = [ + // These custom properties are retrieved directly from CSSOM + // in videocontrols.xml to get pre-defined style instead of computed + // dimensions, which is why they are not referenced by CSS. + { propName: "--clickToPlay-width", isFromDevTools: false }, + { propName: "--playButton-width", isFromDevTools: false }, + { propName: "--muteButton-width", isFromDevTools: false }, + { propName: "--castingButton-width", isFromDevTools: false }, + { propName: "--closedCaptionButton-width", isFromDevTools: false }, + { propName: "--fullscreenButton-width", isFromDevTools: false }, + { propName: "--durationSpan-width", isFromDevTools: false }, + { propName: "--durationSpan-width-long", isFromDevTools: false }, + { propName: "--positionDurationBox-width", isFromDevTools: false }, + { propName: "--positionDurationBox-width-long", isFromDevTools: false }, + + // These variables are used in a shorthand, but the CSS parser deletes the values + // when expanding the shorthands. See https://github.com/w3c/csswg-drafts/issues/2515 + { propName: "--bezier-diagonal-color", isFromDevTools: true }, + + // This variable is used from CSS embedded in JS in adjustableTitle.js + { propName: "--icon-url", isFromDevTools: false }, + + // These are referenced from devtools files. + { + propName: "--browser-stack-z-index-devtools-splitter", + isFromDevTools: false, + }, + { propName: "--browser-stack-z-index-rdm-toolbar", isFromDevTools: false }, +]; + +// Add suffix to stylesheets' URI so that we always load them here and +// have them parsed. Add a random number so that even if we run this +// test multiple times, it would be unlikely to affect each other. +const kPathSuffix = "?always-parse-css-" + Math.random(); + +function dumpWhitelistItem(item) { + return JSON.stringify(item, (key, value) => { + return value instanceof RegExp ? value.toString() : value; + }); +} + +/** + * Check if an error should be ignored due to matching one of the whitelist + * objects defined in whitelist + * + * @param aErrorObject the error to check + * @return true if the error should be ignored, false otherwise. + */ +function ignoredError(aErrorObject) { + for (let whitelistItem of whitelist) { + let matches = true; + let catchAll = true; + for (let prop of ["sourceName", "errorMessage"]) { + if (whitelistItem.hasOwnProperty(prop)) { + catchAll = false; + if (!whitelistItem[prop].test(aErrorObject[prop] || "")) { + matches = false; + break; + } + } + } + if (catchAll) { + ok( + false, + "A whitelist item is catching all errors. " + + dumpWhitelistItem(whitelistItem) + ); + continue; + } + if (matches) { + whitelistItem.used = true; + let { sourceName, errorMessage } = aErrorObject; + info( + `Ignored error "${errorMessage}" on ${sourceName} ` + + "because of whitelist item " + + dumpWhitelistItem(whitelistItem) + ); + return true; + } + } + return false; +} + +var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry +); +var gChromeMap = new Map(); + +var resHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); +var gResourceMap = []; +function trackResourcePrefix(prefix) { + let uri = Services.io.newURI("resource://" + prefix + "/"); + gResourceMap.unshift([prefix, resHandler.resolveURI(uri)]); +} +trackResourcePrefix("gre"); +trackResourcePrefix("app"); + +function getBaseUriForChromeUri(chromeUri) { + let chromeFile = chromeUri + "nonexistentfile.reallynothere"; + let uri = Services.io.newURI(chromeFile); + let fileUri = gChromeReg.convertChromeURL(uri); + return fileUri.resolve("."); +} + +function parseManifest(manifestUri) { + return fetchFile(manifestUri.spec).then(data => { + for (let line of data.split("\n")) { + let [type, ...argv] = line.split(/\s+/); + if (type == "content" || type == "skin") { + let chromeUri = `chrome://${argv[0]}/${type}/`; + gChromeMap.set(getBaseUriForChromeUri(chromeUri), chromeUri); + } else if (type == "resource") { + trackResourcePrefix(argv[0]); + } + } + }); +} + +function convertToCodeURI(fileUri) { + let baseUri = fileUri; + let path = ""; + while (true) { + let slashPos = baseUri.lastIndexOf("/", baseUri.length - 2); + if (slashPos <= 0) { + // File not accessible from chrome protocol, try resource:// + for (let res of gResourceMap) { + if (fileUri.startsWith(res[1])) { + return fileUri.replace(res[1], "resource://" + res[0] + "/"); + } + } + // Give up and return the original URL. + return fileUri; + } + path = baseUri.slice(slashPos + 1) + path; + baseUri = baseUri.slice(0, slashPos + 1); + if (gChromeMap.has(baseUri)) { + return gChromeMap.get(baseUri) + path; + } + } +} + +function messageIsCSSError(msg) { + // Only care about CSS errors generated by our iframe: + if ( + msg instanceof Ci.nsIScriptError && + msg.category.includes("CSS") && + msg.sourceName.endsWith(kPathSuffix) + ) { + let sourceName = msg.sourceName.slice(0, -kPathSuffix.length); + let msgInfo = { sourceName, errorMessage: msg.errorMessage }; + // Check if this error is whitelisted in whitelist + if (!ignoredError(msgInfo)) { + ok(false, `Got error message for ${sourceName}: ${msg.errorMessage}`); + return true; + } + } + return false; +} + +let imageURIsToReferencesMap = new Map(); +let customPropsToReferencesMap = new Map(); + +function neverMatches(mediaList) { + const perPlatformMediaQueryMap = { + macosx: ["(-moz-platform: macos)"], + win: [ + "(-moz-platform: windows)", + "(-moz-platform: windows-win7)", + "(-moz-platform: windows-win8)", + "(-moz-platform: windows-win10)", + ], + linux: ["(-moz-platform: linux)"], + android: ["(-moz-platform: android)"], + }; + for (let platform in perPlatformMediaQueryMap) { + const inThisPlatform = platform === AppConstants.platform; + for (const media of perPlatformMediaQueryMap[platform]) { + if (inThisPlatform && mediaList.mediaText == "not " + media) { + // This query can't match on this platform. + return true; + } + if (!inThisPlatform && mediaList.mediaText == media) { + // This query only matches on another platform that isn't ours. + return true; + } + } + } + return false; +} + +function processCSSRules(container) { + for (let rule of container.cssRules) { + if (rule.media && neverMatches(rule.media)) { + continue; + } + if (rule.styleSheet) { + processCSSRules(rule.styleSheet); // @import + continue; + } + if (rule.cssRules) { + processCSSRules(rule); // @supports, @media, @layer (block), @keyframes + continue; + } + if (!rule.style) { + continue; // @layer (statement), @font-feature-values, @counter-style + } + // Extract urls from the css text. + // Note: CSSRule.style.cssText always has double quotes around URLs even + // when the original CSS file didn't. + let cssText = rule.style.cssText; + let urls = cssText.match(/url\("[^"]*"\)/g); + // Extract props by searching all "--" preceded by "var(" or a non-word + // character. + let props = cssText.match(/(var\(|\W|^)(--[\w\-]+)/g); + if (!urls && !props) { + continue; + } + + for (let url of urls || []) { + // Remove the url(" prefix and the ") suffix. + url = url.replace(/url\("(.*)"\)/, "$1"); + if (url.startsWith("data:")) { + continue; + } + + // Make the url absolute and remove the ref. + let baseURI = Services.io.newURI(rule.parentStyleSheet.href); + url = Services.io.newURI(url, null, baseURI).specIgnoringRef; + + // Store the image url along with the css file referencing it. + let baseUrl = baseURI.spec.split("?always-parse-css")[0]; + if (!imageURIsToReferencesMap.has(url)) { + imageURIsToReferencesMap.set(url, new Set([baseUrl])); + } else { + imageURIsToReferencesMap.get(url).add(baseUrl); + } + } + + for (let prop of props || []) { + if (prop.startsWith("var(")) { + prop = prop.substring(4); + let prevValue = customPropsToReferencesMap.get(prop) || 0; + customPropsToReferencesMap.set(prop, prevValue + 1); + } else { + // Remove the extra non-word character captured by the regular + // expression if needed. + if (prop[0] != "-") { + prop = prop.substring(1); + } + if (!customPropsToReferencesMap.has(prop)) { + customPropsToReferencesMap.set(prop, undefined); + } + } + } + } +} + +function chromeFileExists(aURI) { + let available = 0; + try { + let channel = NetUtil.newChannel({ + uri: aURI, + loadUsingSystemPrincipal: true, + }); + let stream = channel.open(); + let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sstream.init(stream); + available = sstream.available(); + sstream.close(); + } catch (e) { + if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + dump("Checking " + aURI + ": " + e + "\n"); + console.error(e); + } + } + return available > 0; +} + +add_task(async function checkAllTheCSS() { + // Since we later in this test use Services.console.getMessageArray(), + // better to not have some messages from previous tests in the array. + Services.console.reset(); + + let appDir = Services.dirsvc.get("GreD", Ci.nsIFile); + // This asynchronously produces a list of URLs (sadly, mostly sync on our + // test infrastructure because it runs against jarfiles there, and + // our zipreader APIs are all sync) + let uris = await generateURIsFromDirTree(appDir, [".css", ".manifest"]); + + // Create a clean iframe to load all the files into. This needs to live at a + // chrome URI so that it's allowed to load and parse any styles. + let testFile = getRootDirectory(gTestPath) + "dummy_page.html"; + let { HiddenFrame } = ChromeUtils.importESModule( + "resource://gre/modules/HiddenFrame.sys.mjs" + ); + let hiddenFrame = new HiddenFrame(); + let win = await hiddenFrame.get(); + let iframe = win.document.createElementNS( + "http://www.w3.org/1999/xhtml", + "html:iframe" + ); + win.document.documentElement.appendChild(iframe); + let iframeLoaded = BrowserTestUtils.waitForEvent(iframe, "load", true); + iframe.contentWindow.location = testFile; + await iframeLoaded; + let doc = iframe.contentWindow.document; + iframe.contentWindow.docShell.cssErrorReportingEnabled = true; + + // Parse and remove all manifests from the list. + // NOTE that this must be done before filtering out devtools paths + // so that all chrome paths can be recorded. + let manifestURIs = []; + uris = uris.filter(uri => { + if (uri.pathQueryRef.endsWith(".manifest")) { + manifestURIs.push(uri); + return false; + } + return true; + }); + // Wait for all manifest to be parsed + await PerfTestHelpers.throttledMapPromises(manifestURIs, parseManifest); + + // filter out either the devtools paths or the non-devtools paths: + let isDevtools = SimpleTest.harnessParameters.subsuite == "devtools"; + let devtoolsPathBits = ["devtools"]; + uris = uris.filter( + uri => isDevtools == devtoolsPathBits.some(path => uri.spec.includes(path)) + ); + + let loadCSS = chromeUri => + new Promise(resolve => { + let linkEl, onLoad, onError; + onLoad = e => { + processCSSRules(linkEl.sheet); + resolve(); + linkEl.removeEventListener("load", onLoad); + linkEl.removeEventListener("error", onError); + }; + onError = e => { + ok( + false, + "Loading " + linkEl.getAttribute("href") + " threw an error!" + ); + resolve(); + linkEl.removeEventListener("load", onLoad); + linkEl.removeEventListener("error", onError); + }; + linkEl = doc.createElement("link"); + linkEl.setAttribute("rel", "stylesheet"); + linkEl.setAttribute("type", "text/css"); + linkEl.addEventListener("load", onLoad); + linkEl.addEventListener("error", onError); + linkEl.setAttribute("href", chromeUri + kPathSuffix); + doc.head.appendChild(linkEl); + }); + + // We build a list of promises that get resolved when their respective + // files have loaded and produced no errors. + const kInContentCommonCSS = "chrome://global/skin/in-content/common.css"; + let allPromises = uris + .map(uri => convertToCodeURI(uri.spec)) + .filter(uri => uri !== kInContentCommonCSS); + + // Make sure chrome://global/skin/in-content/common.css is loaded before other + // stylesheets in order to guarantee the --in-content variables can be + // correctly referenced. + if (allPromises.length !== uris.length) { + await loadCSS(kInContentCommonCSS); + } + + // Wait for all the files to have actually loaded: + await PerfTestHelpers.throttledMapPromises(allPromises, loadCSS); + + // Check if all the files referenced from CSS actually exist. + // Files in browser/ should never be referenced outside browser/. + for (let [image, references] of imageURIsToReferencesMap) { + if (!chromeFileExists(image)) { + for (let ref of references) { + ok(false, "missing " + image + " referenced from " + ref); + } + } + + let imageHost = image.split("/")[2]; + if (imageHost == "browser") { + for (let ref of references) { + let refHost = ref.split("/")[2]; + if (!["activity-stream", "browser"].includes(refHost)) { + ok( + false, + "browser file " + image + " referenced outside browser in " + ref + ); + } + } + } + } + + // Check if all the properties that are defined are referenced. + for (let [prop, refCount] of customPropsToReferencesMap) { + if (!refCount) { + let ignored = false; + for (let item of propNameWhitelist) { + if (item.propName == prop && isDevtools == item.isFromDevTools) { + item.used = true; + if ( + !item.platforms || + item.platforms.includes(AppConstants.platform) + ) { + ignored = true; + } + break; + } + } + if (!ignored) { + ok(false, "custom property `" + prop + "` is not referenced"); + } + } + } + + let messages = Services.console.getMessageArray(); + // Count errors (the test output will list actual issues for us, as well + // as the ok(false) in messageIsCSSError. + let errors = messages.filter(messageIsCSSError); + is( + errors.length, + 0, + "All the styles (" + allPromises.length + ") loaded without errors." + ); + + // Confirm that all whitelist rules have been used. + function checkWhitelist(list) { + for (let item of list) { + if ( + !item.used && + isDevtools == item.isFromDevTools && + (!item.platforms || item.platforms.includes(AppConstants.platform)) && + !item.intermittent + ) { + ok(false, "Unused whitelist item: " + dumpWhitelistItem(item)); + } + } + } + checkWhitelist(whitelist); + checkWhitelist(propNameWhitelist); + + // Clean up to avoid leaks: + doc.head.innerHTML = ""; + doc = null; + iframe.remove(); + iframe = null; + win = null; + hiddenFrame.destroy(); + hiddenFrame = null; + imageURIsToReferencesMap = null; + customPropsToReferencesMap = null; +}); diff --git a/browser/base/content/test/static/browser_parsable_script.js b/browser/base/content/test/static/browser_parsable_script.js new file mode 100644 index 0000000000..982ef2f91a --- /dev/null +++ b/browser/base/content/test/static/browser_parsable_script.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* This list allows pre-existing or 'unfixable' JS issues to remain, while we + * detect newly occurring issues in shipping JS. It is a list of regexes + * matching files which have errors: + */ + +requestLongerTimeout(2); + +const kWhitelist = new Set([ + /browser\/content\/browser\/places\/controller.js$/, +]); + +const kESModuleList = new Set([ + /browser\/lockwise-card.js$/, + /browser\/monitor-card.js$/, + /browser\/proxy-card.js$/, + /browser\/vpn-card.js$/, + /toolkit\/content\/global\/certviewer\/components\/.*\.js$/, + /toolkit\/content\/global\/certviewer\/.*\.js$/, + /chrome\/pdfjs\/content\/web\/.*\.js$/, +]); + +// Normally we would use reflect.jsm to get Reflect.parse. However, if +// we do that, then all the AST data is allocated in reflect.jsm's +// zone. That exposes a bug in our GC. The GC collects reflect.jsm's +// zone but not the zone in which our test code lives (since no new +// data is being allocated in it). The cross-compartment wrappers in +// our zone that point to the AST data never get collected, and so the +// AST data itself is never collected. We need to GC both zones at +// once to fix the problem. +const init = Cc["@mozilla.org/jsreflect;1"].createInstance(); +init(); + +/** + * Check if an error should be ignored due to matching one of the whitelist + * objects defined in kWhitelist + * + * @param uri the uri to check against the whitelist + * @return true if the uri should be skipped, false otherwise. + */ +function uriIsWhiteListed(uri) { + for (let whitelistItem of kWhitelist) { + if (whitelistItem.test(uri.spec)) { + return true; + } + } + return false; +} + +/** + * Check if a URI should be parsed as an ES module. + * + * @param uri the uri to check against the ES module list + * @return true if the uri should be parsed as a module, otherwise parse it as a script. + */ +function uriIsESModule(uri) { + if (uri.filePath.endsWith(".mjs")) { + return true; + } + + for (let whitelistItem of kESModuleList) { + if (whitelistItem.test(uri.spec)) { + return true; + } + } + return false; +} + +function parsePromise(uri, parseTarget) { + let promise = new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", uri, true); + xhr.onreadystatechange = function () { + if (this.readyState == this.DONE) { + let scriptText = this.responseText; + try { + info(`Checking ${parseTarget} ${uri}`); + let parseOpts = { + source: uri, + target: parseTarget, + }; + Reflect.parse(scriptText, parseOpts); + resolve(true); + } catch (ex) { + let errorMsg = "Script error reading " + uri + ": " + ex; + ok(false, errorMsg); + resolve(false); + } + } + }; + xhr.onerror = error => { + ok(false, "XHR error reading " + uri + ": " + error); + resolve(false); + }; + xhr.overrideMimeType("application/javascript"); + xhr.send(null); + }); + return promise; +} + +add_task(async function checkAllTheJS() { + // In debug builds, even on a fast machine, collecting the file list may take + // more than 30 seconds, and parsing all files may take four more minutes. + // For this reason, this test must be explictly requested in debug builds by + // using the "--setpref parse=<filter>" argument to mach. You can specify: + // - A case-sensitive substring of the file name to test (slow). + // - A single absolute URI printed out by a previous run (fast). + // - An empty string to run the test on all files (slowest). + let parseRequested = Services.prefs.prefHasUserValue("parse"); + let parseValue = parseRequested && Services.prefs.getCharPref("parse"); + if (SpecialPowers.isDebugBuild) { + if (!parseRequested) { + ok( + true, + "Test disabled on debug build. To run, execute: ./mach" + + " mochitest-browser --setpref parse=<case_sensitive_filter>" + + " browser/base/content/test/general/browser_parsable_script.js" + ); + return; + } + // Request a 15 minutes timeout (30 seconds * 30) for debug builds. + requestLongerTimeout(30); + } + + let uris; + // If an absolute URI is specified on the command line, use it immediately. + if (parseValue && parseValue.includes(":")) { + uris = [NetUtil.newURI(parseValue)]; + } else { + let appDir = Services.dirsvc.get("GreD", Ci.nsIFile); + // This asynchronously produces a list of URLs (sadly, mostly sync on our + // test infrastructure because it runs against jarfiles there, and + // our zipreader APIs are all sync) + let startTimeMs = Date.now(); + info("Collecting URIs"); + uris = await generateURIsFromDirTree(appDir, [".js", ".jsm", ".mjs"]); + info("Collected URIs in " + (Date.now() - startTimeMs) + "ms"); + + // Apply the filter specified on the command line, if any. + if (parseValue) { + uris = uris.filter(uri => { + if (uri.spec.includes(parseValue)) { + return true; + } + info("Not checking filtered out " + uri.spec); + return false; + }); + } + } + + // We create an array of promises so we can parallelize all our parsing + // and file loading activity: + await PerfTestHelpers.throttledMapPromises(uris, uri => { + if (uriIsWhiteListed(uri)) { + info("Not checking whitelisted " + uri.spec); + return undefined; + } + let target = "script"; + if (uriIsESModule(uri)) { + target = "module"; + } + return parsePromise(uri.spec, target); + }); + ok(true, "All files parsed"); +}); diff --git a/browser/base/content/test/static/browser_sentence_case_strings.js b/browser/base/content/test/static/browser_sentence_case_strings.js new file mode 100644 index 0000000000..e995f76b1a --- /dev/null +++ b/browser/base/content/test/static/browser_sentence_case_strings.js @@ -0,0 +1,279 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test file checks that our en-US builds use sentence case strings + * where appropriate. It's not exhaustive - some panels will show different + * items in different states, and this test doesn't iterate all of them. + */ + +/* global PanelUI */ + +const { CustomizableUITestUtils } = ChromeUtils.importESModule( + "resource://testing-common/CustomizableUITestUtils.sys.mjs" +); + +const { AppMenuNotifications } = ChromeUtils.importESModule( + "resource://gre/modules/AppMenuNotifications.sys.mjs" +); + +// These are brand names, proper names, or other things that we expect to +// not abide exactly to sentence case. NAMES is for single words, and PHRASES +// is for words in a specific order. +const NAMES = new Set(["Mozilla", "Nightly", "Firefox"]); +const PHRASES = new Set(["Troubleshoot Modeā¦"]); + +let gCUITestUtils = new CustomizableUITestUtils(window); +let gLocalization = new Localization(["browser/newtab/asrouter.ftl"], true); + +/** + * This recursive function will take the current main or subview, find all of + * the buttons that navigate to subviews inside it, and click each one + * individually. Upon entering the new view, we recurse. When the subviews + * within a view have been exhausted, we go back up a level. + * + * @generator + * @param {<xul:panelview>} parentView The view to start scanning for + * subviews. + * @yields {<xul:panelview>} Each found <xul:panelview>, in depth-first search + * order. + */ +async function* iterateSubviews(parentView) { + let navButtons = Array.from( + // Ensure that only enabled buttons are tested + parentView.querySelectorAll(".subviewbutton-nav:not([disabled])") + ); + if (!navButtons) { + return; + } + + for (let button of navButtons) { + info("Click " + button.id); + let panel = parentView.closest("panel"); + let panelmultiview = parentView.closest("panelmultiview"); + let promiseViewShown = BrowserTestUtils.waitForEvent(panel, "ViewShown"); + button.click(); + let viewShownEvent = await promiseViewShown; + + yield viewShownEvent.originalTarget; + + info("Shown " + viewShownEvent.originalTarget.id); + yield* iterateSubviews(viewShownEvent.originalTarget); + promiseViewShown = BrowserTestUtils.waitForEvent(parentView, "ViewShown"); + panelmultiview.goBack(); + await promiseViewShown; + } +} + +/** + * Given a <xul:panelview>, look for <xul:toolbarbutton> descendants, extract + * any relevant strings from them, and check to see if they are in sentence + * case. By default, labels, textContent, and toolTipText (including dynamic + * toolTipText) are checked. + * + * @param {<xul:panelview>} view The <xul:panelview> to check. + */ +function checkToolbarButtons(view) { + let toolbarbuttons = view.querySelectorAll("toolbarbutton"); + info("Checking toolbarbuttons in subview with id " + view.id); + + for (let toolbarbutton of toolbarbuttons) { + let strings = [ + toolbarbutton.label, + toolbarbutton.textContent, + toolbarbutton.toolTipText, + GetDynamicShortcutTooltipText(toolbarbutton.id), + ]; + info("Checking toolbarbutton " + toolbarbutton.id); + for (let string of strings) { + checkSentenceCase(string, toolbarbutton.id); + } + } +} + +function checkSubheaders(view) { + let subheaders = view.querySelectorAll("h2"); + info("Checking subheaders in subview with id " + view.id); + + for (let subheader of subheaders) { + checkSentenceCase(subheader.textContent, subheader.id); + } +} + +async function checkUpdateBanner(view) { + let banner = view.querySelector("#appMenu-proton-update-banner"); + + const notifications = [ + "update-downloading", + "update-available", + "update-manual", + "update-unsupported", + "update-restart", + ]; + + for (const notification of notifications) { + // Forcibly remove the label in order to wait for the new label. + banner.removeAttribute("label"); + + let labelPromise = BrowserTestUtils.waitForMutationCondition( + banner, + { attributes: true, attributeFilter: ["label"] }, + () => !!banner.getAttribute("label") + ); + + AppMenuNotifications.showNotification(notification); + + await labelPromise; + + checkSentenceCase(banner.label, banner.id); + + AppMenuNotifications.removeNotification(/.*/); + } +} + +/** + * Asserts whether or not a string matches sentence case. + * + * @param {String} string The string to check for sentence case. + * @param {String} elementID The ID of the element being tested. This is + * mainly used for the assertion message to make it easier to debug + * failures, but items without IDs will not be checked (as these are + * likely using dynamic strings, like bookmarked page titles). + */ +function checkSentenceCase(string, elementID) { + if (!string || !elementID) { + return; + } + + info("Testing string: " + string); + + let words = string.trim().split(/\s+/); + + // We expect that the first word is always capitalized. If it isn't, + // there's no need to keep checking the rest of the string, since we're + // going to fail the assertion. + let result = hasExpectedCapitalization(words[0], true); + if (result) { + for (let wordIndex = 1; wordIndex < words.length; ++wordIndex) { + let word = words[wordIndex]; + + if (word) { + if (isPartOfPhrase(words, wordIndex)) { + result = hasExpectedCapitalization(word, true); + } else { + let isName = NAMES.has(word); + result = hasExpectedCapitalization(word, isName); + } + if (!result) { + break; + } + } + } + } + + Assert.ok(result, `${string} for ${elementID} should have sentence casing.`); +} + +/** + * Returns true if a word is part of a phrase defined in the PHRASES set. + * The function will see if the word is contained within any of the defined + * PHRASES, and will then scan back and forward within the words array to + * to see if the word is indeed part of the phrase in context. + * + * @param {Array} words The full array of words being checked by the caller. + * @param {Number} wordIndex The index of the word being checked within the + * words array. + * @return {Boolean} + */ +function isPartOfPhrase(words, wordIndex) { + let word = words[wordIndex]; + + info(`Checking if ${word} is part of a phrase`); + + for (let phrase of PHRASES) { + let phraseFragments = phrase.split(" "); + let fragmentIndex = phraseFragments.indexOf(word); + + // If we didn't find the word within this phrase, the candidate phrase + // has more words than what we're analyzing, or the word doesn't have + // enough words before it to match the candidate phrase, then move on. + if ( + fragmentIndex == -1 || + words.length - phraseFragments.length < 0 || + fragmentIndex > wordIndex + ) { + continue; + } + + let wordsSlice = words.slice( + wordIndex - fragmentIndex, + wordIndex + phraseFragments.length + ); + let matches = wordsSlice.every((w, index) => { + return phraseFragments[index] === w; + }); + + if (matches) { + info(`${word} is part of phrase ${phrase}`); + return true; + } + } + + return false; +} + +/** + * Tests that the strings under the AppMenu are in sentence case. + */ +add_task(async function test_sentence_case_appmenu() { + // Some of these panels are lazy, so it's necessary to open them in + // order for them to be inserted into the DOM. + await gCUITestUtils.openMainMenu(); + registerCleanupFunction(async () => { + await gCUITestUtils.hideMainMenu(); + }); + + checkToolbarButtons(PanelUI.mainView); + checkSubheaders(PanelUI.mainView); + + for await (const view of iterateSubviews(PanelUI.mainView)) { + checkToolbarButtons(view); + checkSubheaders(view); + } + + await checkUpdateBanner(PanelUI.mainView); +}); + +/** + * Tests that the strings under the All Tabs panel are in sentence case. + */ +add_task(async function test_sentence_case_all_tabs_panel() { + gTabsPanel.init(); + + const allTabsView = document.getElementById("allTabsMenu-allTabsView"); + let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent( + allTabsView, + "ViewShown" + ); + gTabsPanel.showAllTabsPanel(); + await allTabsPopupShownPromise; + + registerCleanupFunction(async () => { + let allTabsPopupHiddenPromise = BrowserTestUtils.waitForEvent( + allTabsView.panelMultiView, + "PanelMultiViewHidden" + ); + gTabsPanel.hideAllTabsPanel(); + await allTabsPopupHiddenPromise; + }); + + checkToolbarButtons(gTabsPanel.allTabsView); + checkSubheaders(gTabsPanel.allTabsView); + + for await (const view of iterateSubviews(gTabsPanel.allTabsView)) { + checkToolbarButtons(view); + checkSubheaders(view); + } +}); diff --git a/browser/base/content/test/static/browser_title_case_menus.js b/browser/base/content/test/static/browser_title_case_menus.js new file mode 100644 index 0000000000..9251db057b --- /dev/null +++ b/browser/base/content/test/static/browser_title_case_menus.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This test file checks that our en-US builds use APA-style Title Case strings + * where appropriate. + */ + +// MINOR_WORDS are words that are okay to not be capitalized when they're +// mid-string. +// +// Source: https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case +const MINOR_WORDS = [ + "a", + "an", + "and", + "as", + "at", + "but", + "by", + "for", + "if", + "in", + "nor", + "of", + "off", + "on", + "or", + "per", + "so", + "the", + "to", + "up", + "via", + "yet", +]; + +/** + * Returns a generator that will yield all of the <xul:menupopups> + * beneath <xul:menu> elements within a given <xul:menubar>. Each + * <xul:menupopup> will have the "popupshowing" and "popupshown" + * event fired on them to give them an opportunity to fully populate + * themselves before being yielded. + * + * @generator + * @param {<xul:menubar>} menubar The <xul:menubar> to get <xul:menupopup>s + * for. + * @yields {<xul:menupopup>} The next <xul:menupopup> under the <xul:menubar>. + */ +async function* iterateMenuPopups(menubar) { + let menus = menubar.querySelectorAll("menu"); + + for (let menu of menus) { + for (let menupopup of menu.querySelectorAll("menupopup")) { + // We fake the popupshowing and popupshown events to give the menupopups + // an opportunity to fully populate themselves. We don't actually open + // the menupopups because this is not possible on macOS. + menupopup.dispatchEvent( + new MouseEvent("popupshowing", { bubbles: true }) + ); + menupopup.dispatchEvent(new MouseEvent("popupshown", { bubbles: true })); + + yield menupopup; + + // Just for good measure, we'll fire the popuphiding/popuphidden events + // after we close the menupopups. + menupopup.dispatchEvent(new MouseEvent("popuphiding", { bubbles: true })); + menupopup.dispatchEvent(new MouseEvent("popuphidden", { bubbles: true })); + } + } +} + +/** + * Given a <xul:menupopup>, checks all of the child elements with label + * properties to see if those labels are Title Cased. Skips any elements that + * have an empty or undefined label property. + * + * @param {<xul:menupopup>} menupopup The <xul:menupopup> to check. + */ +function checkMenuItems(menupopup) { + info("Checking menupopup with id " + menupopup.id); + for (let child of menupopup.children) { + if (child.label) { + info("Checking menupopup child with id " + child.id); + checkTitleCase(child.label, child.id); + } + } +} + +/** + * Given a string, checks that the string is in Title Case. + * + * @param {String} string The string to check. + * @param {String} elementID The ID of the element associated with the string. + * This is included in the assertion message. + */ +function checkTitleCase(string, elementID) { + if (!string || !elementID /* document this */) { + return; + } + + let words = string.trim().split(/\s+/); + + // We extract the first word, and always expect it to be capitalized, + // even if it's a short word like one of MINOR_WORDS. + let firstWord = words.shift(); + let result = hasExpectedCapitalization(firstWord, true); + if (result) { + for (let word of words) { + if (word) { + let expectCapitalized = !MINOR_WORDS.includes(word); + result = hasExpectedCapitalization(word, expectCapitalized); + if (!result) { + break; + } + } + } + } + + Assert.ok(result, `${string} for ${elementID} should have Title Casing.`); +} + +/** + * On Windows, macOS and GTK/KDE Linux, menubars are expected to be in Title + * Case in order to feel native. This test iterates the menuitem labels of the + * main menubar to ensure the en-US strings are all in Title Case. + * + * We use APA-style Title Case for the menubar, rather than Photon-style Title + * Case (https://design.firefox.com/photon/copy/capitalization.html) to match + * the native platform conventions. + */ +add_task(async function apa_test_title_case_menubar() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let menuToolbar = newWin.document.getElementById("main-menubar"); + + for await (const menupopup of iterateMenuPopups(menuToolbar)) { + checkMenuItems(menupopup); + } + + await BrowserTestUtils.closeWindow(newWin); +}); + +/** + * This test iterates the menuitem labels of the macOS dock menu for the + * application to ensure the en-US strings are all in Title Case. + */ +add_task(async function apa_test_title_case_macos_dock_menu() { + if (AppConstants.platform != "macosx") { + return; + } + + let hiddenWindow = Services.appShell.hiddenDOMWindow; + Assert.ok(hiddenWindow, "Could get at hidden window"); + let menupopup = hiddenWindow.document.getElementById("menu_mac_dockmenu"); + checkMenuItems(menupopup); +}); diff --git a/browser/base/content/test/static/bug1262648_string_with_newlines.dtd b/browser/base/content/test/static/bug1262648_string_with_newlines.dtd new file mode 100644 index 0000000000..86cbefa5bd --- /dev/null +++ b/browser/base/content/test/static/bug1262648_string_with_newlines.dtd @@ -0,0 +1,3 @@ +<!ENTITY foo.bar "This string +contains +newlines!"> diff --git a/browser/base/content/test/static/dummy_page.html b/browser/base/content/test/static/dummy_page.html new file mode 100644 index 0000000000..1a87e28408 --- /dev/null +++ b/browser/base/content/test/static/dummy_page.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +</body> +</html> diff --git a/browser/base/content/test/static/head.js b/browser/base/content/test/static/head.js new file mode 100644 index 0000000000..d9b978e853 --- /dev/null +++ b/browser/base/content/test/static/head.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* Shorthand constructors to construct an nsI(Local)File and zip reader: */ +const LocalFile = new Components.Constructor( + "@mozilla.org/file/local;1", + Ci.nsIFile, + "initWithPath" +); +const ZipReader = new Components.Constructor( + "@mozilla.org/libjar/zip-reader;1", + "nsIZipReader", + "open" +); + +const IS_ALPHA = /^[a-z]+$/i; + +var { PerfTestHelpers } = ChromeUtils.importESModule( + "resource://testing-common/PerfTestHelpers.sys.mjs" +); + +/** + * Returns a promise that is resolved with a list of files that have one of the + * extensions passed, represented by their nsIURI objects, which exist inside + * the directory passed. + * + * @param dir the directory which to scan for files (nsIFile) + * @param extensions the extensions of files we're interested in (Array). + */ +function generateURIsFromDirTree(dir, extensions) { + if (!Array.isArray(extensions)) { + extensions = [extensions]; + } + let dirQueue = [dir.path]; + return (async function () { + let rv = []; + while (dirQueue.length) { + let nextDir = dirQueue.shift(); + let { subdirs, files } = await iterateOverPath(nextDir, extensions); + dirQueue.push(...subdirs); + rv.push(...files); + } + return rv; + })(); +} + +/** + * Iterate over the children of |path| and find subdirectories and files with + * the given extension. + * + * This function recurses into ZIP and JAR archives as well. + * + * @param {string} path The path to check. + * @param {string[]} extensions The file extensions we're interested in. + * + * @returns {Promise<object>} + * A promise that resolves to an object containing the following + * properties: + * - files: an array of nsIURIs corresponding to + * files that match the extensions passed + * - subdirs: an array of paths for subdirectories we need to recurse + * into (handled by generateURIsFromDirTree above) + */ +async function iterateOverPath(path, extensions) { + const children = await IOUtils.getChildren(path); + + const files = []; + const subdirs = []; + + for (const entry of children) { + let stat; + try { + stat = await IOUtils.stat(entry); + } catch (error) { + if (error.name === "NotFoundError") { + // Ignore symlinks from prior builds to subsequently removed files + continue; + } + throw error; + } + + if (stat.type === "directory") { + subdirs.push(entry); + } else if (extensions.some(extension => entry.endsWith(extension))) { + if (await IOUtils.exists(entry)) { + const spec = PathUtils.toFileURI(entry); + files.push(Services.io.newURI(spec)); + } + } else if ( + entry.endsWith(".ja") || + entry.endsWith(".jar") || + entry.endsWith(".zip") || + entry.endsWith(".xpi") + ) { + const file = new LocalFile(entry); + for (const extension of extensions) { + files.push(...generateEntriesFromJarFile(file, extension)); + } + } + } + + return { files, subdirs }; +} + +/* Helper function to generate a URI spec (NB: not an nsIURI yet!) + * given an nsIFile object */ +function getURLForFile(file) { + let fileHandler = Services.io.getProtocolHandler("file"); + fileHandler = fileHandler.QueryInterface(Ci.nsIFileProtocolHandler); + return fileHandler.getURLSpecFromActualFile(file); +} + +/** + * A generator that generates nsIURIs for particular files found in jar files + * like omni.ja. + * + * @param jarFile an nsIFile object for the jar file that needs checking. + * @param extension the extension we're interested in. + */ +function* generateEntriesFromJarFile(jarFile, extension) { + let zr = new ZipReader(jarFile); + const kURIStart = getURLForFile(jarFile); + + for (let entry of zr.findEntries("*" + extension + "$")) { + // Ignore the JS cache which is stored in omni.ja + if (entry.startsWith("jsloader") || entry.startsWith("jssubloader")) { + continue; + } + let entryURISpec = "jar:" + kURIStart + "!/" + entry; + yield Services.io.newURI(entryURISpec); + } + zr.close(); +} + +function fetchFile(uri) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.responseType = "text"; + xhr.open("GET", uri, true); + xhr.onreadystatechange = function () { + if (this.readyState != this.DONE) { + return; + } + try { + resolve(this.responseText); + } catch (ex) { + ok(false, `Script error reading ${uri}: ${ex}`); + resolve(""); + } + }; + xhr.onerror = error => { + ok(false, `XHR error reading ${uri}: ${error}`); + resolve(""); + }; + xhr.send(null); + }); +} + +/** + * Returns whether or not a word (presumably in en-US) is capitalized per + * expectations. + * + * @param {String} word The single word to check. + * @param {boolean} expectCapitalized True if the word should be capitalized. + * @returns {boolean} True if the word matches the expected capitalization. + */ +function hasExpectedCapitalization(word, expectCapitalized) { + let firstChar = word[0]; + if (!IS_ALPHA.test(firstChar)) { + return true; + } + + let isCapitalized = firstChar == firstChar.toLocaleUpperCase("en-US"); + return isCapitalized == expectCapitalized; +} |