diff options
Diffstat (limited to '')
64 files changed, 5672 insertions, 0 deletions
diff --git a/dom/l10n/DOMLocalization.cpp b/dom/l10n/DOMLocalization.cpp new file mode 100644 index 0000000000..456b67ab22 --- /dev/null +++ b/dom/l10n/DOMLocalization.cpp @@ -0,0 +1,672 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "js/ForOfIterator.h" // JS::ForOfIterator +#include "js/JSON.h" // JS_ParseJSON +#include "nsContentUtils.h" +#include "nsIScriptError.h" +#include "DOMLocalization.h" +#include "mozilla/intl/L10nRegistry.h" +#include "mozilla/intl/LocaleService.h" +#include "mozilla/dom/AutoEntryScript.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/L10nOverlays.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::intl; + +NS_IMPL_CYCLE_COLLECTION_CLASS(DOMLocalization) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DOMLocalization, Localization) + tmp->DisconnectMutations(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mMutations) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mRoots) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DOMLocalization, Localization) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mMutations) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mRoots) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_ADDREF_INHERITED(DOMLocalization, Localization) +NS_IMPL_RELEASE_INHERITED(DOMLocalization, Localization) +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DOMLocalization) +NS_INTERFACE_MAP_END_INHERITING(Localization) + +DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aSync) + : Localization(aGlobal, aSync) { + mMutations = new L10nMutations(this); +} + +DOMLocalization::DOMLocalization(nsIGlobalObject* aGlobal, bool aIsSync, + const ffi::LocalizationRc* aRaw) + : Localization(aGlobal, aIsSync, aRaw) { + mMutations = new L10nMutations(this); +} + +already_AddRefed<DOMLocalization> DOMLocalization::Constructor( + const GlobalObject& aGlobal, + const Sequence<dom::OwningUTF8StringOrResourceId>& aResourceIds, + bool aIsSync, const Optional<NonNull<L10nRegistry>>& aRegistry, + const Optional<Sequence<nsCString>>& aLocales, ErrorResult& aRv) { + auto ffiResourceIds{L10nRegistry::ResourceIdsToFFI(aResourceIds)}; + Maybe<nsTArray<nsCString>> locales; + + if (aLocales.WasPassed()) { + locales.emplace(); + locales->SetCapacity(aLocales.Value().Length()); + for (const auto& locale : aLocales.Value()) { + locales->AppendElement(locale); + } + } + + RefPtr<const ffi::LocalizationRc> raw; + bool result; + + if (aRegistry.WasPassed()) { + result = ffi::localization_new_with_locales( + &ffiResourceIds, aIsSync, aRegistry.Value().Raw(), + locales.ptrOr(nullptr), getter_AddRefs(raw)); + } else { + result = ffi::localization_new_with_locales(&ffiResourceIds, aIsSync, + nullptr, locales.ptrOr(nullptr), + getter_AddRefs(raw)); + } + + if (result) { + nsCOMPtr<nsIGlobalObject> global = + do_QueryInterface(aGlobal.GetAsSupports()); + + return do_AddRef(new DOMLocalization(global, aIsSync, raw)); + } + aRv.ThrowInvalidStateError( + "Failed to create the Localization. Check the locales arguments."); + return nullptr; +} + +JSObject* DOMLocalization::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return DOMLocalization_Binding::Wrap(aCx, this, aGivenProto); +} + +void DOMLocalization::Destroy() { DisconnectMutations(); } + +DOMLocalization::~DOMLocalization() { Destroy(); } + +bool DOMLocalization::HasPendingMutations() const { + return mMutations && mMutations->HasPendingMutations(); +} + +/** + * DOMLocalization API + */ + +void DOMLocalization::ConnectRoot(nsINode& aNode) { + nsCOMPtr<nsIGlobalObject> global = aNode.GetOwnerGlobal(); + if (!global) { + return; + } + MOZ_ASSERT(global == mGlobal, + "Cannot add a root that overlaps with existing root."); + +#ifdef DEBUG + for (nsINode* root : mRoots) { + MOZ_ASSERT( + root != &aNode && !root->Contains(&aNode) && !aNode.Contains(root), + "Cannot add a root that overlaps with existing root."); + } +#endif + + mRoots.Insert(&aNode); + + aNode.AddMutationObserverUnlessExists(mMutations); +} + +void DOMLocalization::DisconnectRoot(nsINode& aNode) { + if (mRoots.Contains(&aNode)) { + aNode.RemoveMutationObserver(mMutations); + mRoots.Remove(&aNode); + } +} + +void DOMLocalization::PauseObserving() { mMutations->PauseObserving(); } + +void DOMLocalization::ResumeObserving() { mMutations->ResumeObserving(); } + +void DOMLocalization::SetAttributes( + JSContext* aCx, Element& aElement, const nsAString& aId, + const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv) { + if (aArgs.WasPassed() && aArgs.Value()) { + nsAutoString data; + JS::Rooted<JS::Value> val(aCx, JS::ObjectValue(*aArgs.Value())); + if (!nsContentUtils::StringifyJSON(aCx, val, data, + UndefinedIsNullStringLiteral)) { + aRv.NoteJSContextException(aCx); + return; + } + if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, + eCaseMatters)) { + aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, true); + } + } else { + aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, true); + } + + if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, + eCaseMatters)) { + aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, aId, true); + } +} + +void DOMLocalization::GetAttributes(Element& aElement, L10nIdArgs& aResult, + ErrorResult& aRv) { + nsAutoString l10nId; + nsAutoString l10nArgs; + + if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nid, l10nId)) { + CopyUTF16toUTF8(l10nId, aResult.mId); + } + + if (aElement.GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, l10nArgs)) { + ConvertStringToL10nArgs(l10nArgs, aResult.mArgs.SetValue(), aRv); + } +} + +void DOMLocalization::SetArgs(JSContext* aCx, Element& aElement, + const Optional<JS::Handle<JSObject*>>& aArgs, + ErrorResult& aRv) { + if (aArgs.WasPassed() && aArgs.Value()) { + nsAutoString data; + JS::Rooted<JS::Value> val(aCx, JS::ObjectValue(*aArgs.Value())); + if (!nsContentUtils::StringifyJSON(aCx, val, data, + UndefinedIsNullStringLiteral)) { + aRv.NoteJSContextException(aCx); + return; + } + if (!aElement.AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, + eCaseMatters)) { + aElement.SetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, data, true); + } + } else { + aElement.UnsetAttr(kNameSpaceID_None, nsGkAtoms::datal10nargs, true); + } +} + +already_AddRefed<Promise> DOMLocalization::TranslateFragment(nsINode& aNode, + ErrorResult& aRv) { + Sequence<OwningNonNull<Element>> elements; + GetTranslatables(aNode, elements, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + return TranslateElements(elements, aRv); +} + +/** + * A Promise Handler used to apply the result of + * a call to Localization::FormatMessages onto the list + * of translatable elements. + */ +class ElementTranslationHandler : public PromiseNativeHandler { + public: + explicit ElementTranslationHandler(DOMLocalization* aDOMLocalization, + nsXULPrototypeDocument* aProto) + : mDOMLocalization(aDOMLocalization), mProto(aProto){}; + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(ElementTranslationHandler) + + nsTArray<nsCOMPtr<Element>>& Elements() { return mElements; } + + void SetReturnValuePromise(Promise* aReturnValuePromise) { + mReturnValuePromise = aReturnValuePromise; + } + + virtual void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + ErrorResult rv; + + nsTArray<Nullable<L10nMessage>> l10nData; + if (aValue.isObject()) { + JS::ForOfIterator iter(aCx); + if (!iter.init(aValue, JS::ForOfIterator::AllowNonIterable)) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + if (!iter.valueIsIterable()) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + + JS::Rooted<JS::Value> temp(aCx); + while (true) { + bool done; + if (!iter.next(&temp, &done)) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + + if (done) { + break; + } + + Nullable<L10nMessage>* slotPtr = + l10nData.AppendElement(mozilla::fallible); + if (!slotPtr) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + + if (!temp.isNull()) { + if (!slotPtr->SetValue().Init(aCx, temp)) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + } + } + } + + bool allTranslated = + mDOMLocalization->ApplyTranslations(mElements, l10nData, mProto, rv); + if (NS_WARN_IF(rv.Failed()) || !allTranslated) { + mReturnValuePromise->MaybeRejectWithUndefined(); + return; + } + + mReturnValuePromise->MaybeResolveWithUndefined(); + } + + virtual void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + mReturnValuePromise->MaybeRejectWithClone(aCx, aValue); + } + + private: + ~ElementTranslationHandler() = default; + + nsTArray<nsCOMPtr<Element>> mElements; + RefPtr<DOMLocalization> mDOMLocalization; + RefPtr<Promise> mReturnValuePromise; + RefPtr<nsXULPrototypeDocument> mProto; +}; + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(ElementTranslationHandler) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTION_CLASS(ElementTranslationHandler) + +NS_IMPL_CYCLE_COLLECTING_ADDREF(ElementTranslationHandler) +NS_IMPL_CYCLE_COLLECTING_RELEASE(ElementTranslationHandler) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(ElementTranslationHandler) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mElements) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDOMLocalization) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mReturnValuePromise) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mProto) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(ElementTranslationHandler) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mElements) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDOMLocalization) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReturnValuePromise) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mProto) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +already_AddRefed<Promise> DOMLocalization::TranslateElements( + const nsTArray<OwningNonNull<Element>>& aElements, ErrorResult& aRv) { + return TranslateElements(aElements, nullptr, aRv); +} + +already_AddRefed<Promise> DOMLocalization::TranslateElements( + const nsTArray<OwningNonNull<Element>>& aElements, + nsXULPrototypeDocument* aProto, ErrorResult& aRv) { + Sequence<OwningUTF8StringOrL10nIdArgs> l10nKeys; + RefPtr<ElementTranslationHandler> nativeHandler = + new ElementTranslationHandler(this, aProto); + nsTArray<nsCOMPtr<Element>>& domElements = nativeHandler->Elements(); + domElements.SetCapacity(aElements.Length()); + + if (!mGlobal) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + for (auto& domElement : aElements) { + if (!domElement->HasAttr(nsGkAtoms::datal10nid)) { + continue; + } + + OwningUTF8StringOrL10nIdArgs* key = l10nKeys.AppendElement(fallible); + if (!key) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return nullptr; + } + + GetAttributes(*domElement, key->SetAsL10nIdArgs(), aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + if (!domElements.AppendElement(domElement, fallible)) { + // This can't really happen, we SetCapacity'd above... + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return nullptr; + } + } + + RefPtr<Promise> promise = Promise::Create(mGlobal, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + if (IsSync()) { + nsTArray<Nullable<L10nMessage>> l10nMessages; + + FormatMessagesSync(l10nKeys, l10nMessages, aRv); + + if (NS_WARN_IF(aRv.Failed())) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + + bool allTranslated = + ApplyTranslations(domElements, l10nMessages, aProto, aRv); + if (NS_WARN_IF(aRv.Failed()) || !allTranslated) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + + promise->MaybeResolveWithUndefined(); + return promise.forget(); + } + RefPtr<Promise> callbackResult = FormatMessages(l10nKeys, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + nativeHandler->SetReturnValuePromise(promise); + callbackResult->AppendNativeHandler(nativeHandler); + return MaybeWrapPromise(promise); +} + +/** + * Promise handler used to set localization data on + * roots of elements that got successfully translated. + */ +class L10nRootTranslationHandler final : public PromiseNativeHandler { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(L10nRootTranslationHandler) + + explicit L10nRootTranslationHandler(Element* aRoot) : mRoot(aRoot) {} + + void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + DOMLocalization::SetRootInfo(mRoot); + } + + void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override {} + + private: + ~L10nRootTranslationHandler() = default; + + RefPtr<Element> mRoot; +}; + +NS_IMPL_CYCLE_COLLECTION(L10nRootTranslationHandler, mRoot) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nRootTranslationHandler) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nRootTranslationHandler) +NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nRootTranslationHandler) + +already_AddRefed<Promise> DOMLocalization::TranslateRoots(ErrorResult& aRv) { + nsTArray<RefPtr<Promise>> promises; + + for (nsINode* root : mRoots) { + RefPtr<Promise> promise = TranslateFragment(*root, aRv); + if (MOZ_UNLIKELY(aRv.Failed())) { + return nullptr; + } + + // If the root is an element, we'll add a native handler + // to set root info (language, direction etc.) on it + // once the localization finishes. + if (root->IsElement()) { + RefPtr<L10nRootTranslationHandler> nativeHandler = + new L10nRootTranslationHandler(root->AsElement()); + promise->AppendNativeHandler(nativeHandler); + } + + promises.AppendElement(promise); + } + AutoEntryScript aes(mGlobal, "DOMLocalization TranslateRoots"); + return Promise::All(aes.cx(), promises, aRv); +} + +/** + * Helper methods + */ + +/* static */ +void DOMLocalization::GetTranslatables( + nsINode& aNode, Sequence<OwningNonNull<Element>>& aElements, + ErrorResult& aRv) { + nsIContent* node = + aNode.IsContent() ? aNode.AsContent() : aNode.GetFirstChild(); + for (; node; node = node->GetNextNode(&aNode)) { + if (!node->IsElement()) { + continue; + } + + Element* domElement = node->AsElement(); + + if (!domElement->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) { + continue; + } + + if (!aElements.AppendElement(*domElement, fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } +} + +/* static */ +void DOMLocalization::SetRootInfo(Element* aElement) { + nsAutoCString primaryLocale; + LocaleService::GetInstance()->GetAppLocaleAsBCP47(primaryLocale); + aElement->SetAttr(kNameSpaceID_None, nsGkAtoms::lang, + NS_ConvertUTF8toUTF16(primaryLocale), true); + + nsAutoString dir; + if (LocaleService::GetInstance()->IsAppLocaleRTL()) { + nsGkAtoms::rtl->ToString(dir); + } else { + nsGkAtoms::ltr->ToString(dir); + } + + uint32_t nameSpace = aElement->GetNameSpaceID(); + nsAtom* dirAtom = + nameSpace == kNameSpaceID_XUL ? nsGkAtoms::localedir : nsGkAtoms::dir; + + aElement->SetAttr(kNameSpaceID_None, dirAtom, dir, true); +} + +bool DOMLocalization::ApplyTranslations( + nsTArray<nsCOMPtr<Element>>& aElements, + nsTArray<Nullable<L10nMessage>>& aTranslations, + nsXULPrototypeDocument* aProto, ErrorResult& aRv) { + if (aElements.Length() != aTranslations.Length()) { + aRv.Throw(NS_ERROR_FAILURE); + return false; + } + + PauseObserving(); + + bool hasMissingTranslation = false; + + nsTArray<L10nOverlaysError> errors; + for (size_t i = 0; i < aTranslations.Length(); ++i) { + nsCOMPtr elem = aElements[i]; + if (aTranslations[i].IsNull()) { + hasMissingTranslation = true; + continue; + } + // If we have a proto, we expect all elements are connected up. + // If they're not, they may have been removed by earlier translations. + // We will have added an error in L10nOverlays in this case. + // This is an error in fluent use, but shouldn't be crashing. There's + // also no point translating the element - skip it: + if (aProto && !elem->IsInComposedDoc()) { + continue; + } + + // It is possible that someone removed the `data-l10n-id` from the element + // before the async translation completed. In that case, skip applying + // the translation. + if (!elem->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nid)) { + continue; + } + L10nOverlays::TranslateElement(*elem, aTranslations[i].Value(), errors, + aRv); + if (NS_WARN_IF(aRv.Failed())) { + hasMissingTranslation = true; + continue; + } + if (aProto) { + // We only need to rebuild deep if the translation has a value. + // Otherwise we'll only rebuild the attributes. + aProto->RebuildL10nPrototype(elem, + !aTranslations[i].Value().mValue.IsVoid()); + } + } + + ReportL10nOverlaysErrors(errors); + + ResumeObserving(); + + return !hasMissingTranslation; +} + +/* Protected */ + +void DOMLocalization::OnChange() { + Localization::OnChange(); + RefPtr<Promise> promise = TranslateRoots(IgnoreErrors()); +} + +void DOMLocalization::DisconnectMutations() { + if (mMutations) { + mMutations->Disconnect(); + DisconnectRoots(); + } +} + +void DOMLocalization::DisconnectRoots() { + for (nsINode* node : mRoots) { + node->RemoveMutationObserver(mMutations); + } + mRoots.Clear(); +} + +void DOMLocalization::ReportL10nOverlaysErrors( + nsTArray<L10nOverlaysError>& aErrors) { + nsAutoString msg; + + for (auto& error : aErrors) { + if (error.mCode.WasPassed()) { + msg = u"[fluent-dom] "_ns; + switch (error.mCode.Value()) { + case L10nOverlays_Binding::ERROR_FORBIDDEN_TYPE: + msg += u"An element of forbidden type \""_ns + + error.mTranslatedElementName.Value() + + nsLiteralString( + u"\" was found in the translation. Only safe text-level " + "elements and elements with data-l10n-name are allowed."); + break; + case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_MISSING: + msg += u"An element named \""_ns + error.mL10nName.Value() + + u"\" wasn't found in the source."_ns; + break; + case L10nOverlays_Binding::ERROR_NAMED_ELEMENT_TYPE_MISMATCH: + msg += u"An element named \""_ns + error.mL10nName.Value() + + nsLiteralString( + u"\" was found in the translation but its type ") + + error.mTranslatedElementName.Value() + + nsLiteralString( + u" didn't match the element found in the source ") + + error.mSourceElementName.Value() + u"."_ns; + break; + case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISCONNECTED: + msg += u"The element using message \""_ns + error.mL10nName.Value() + + nsLiteralString( + u"\" was removed from the DOM when translating its \"") + + error.mTranslatedElementName.Value() + u"\" parent."_ns; + break; + case L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM: + msg += nsLiteralString( + u"While translating an element with fluent ID \"") + + error.mL10nName.Value() + u"\" a child element of type \""_ns + + error.mTranslatedElementName.Value() + + nsLiteralString( + u"\" was removed. Either the fluent message " + "does not contain markup, or it does not contain markup " + "of this type."); + break; + case L10nOverlays_Binding::ERROR_UNKNOWN: + default: + msg += nsLiteralString( + u"Unknown error happened while translating an element."); + break; + } + nsPIDOMWindowInner* innerWindow = GetParentObject()->AsInnerWindow(); + Document* doc = innerWindow ? innerWindow->GetExtantDoc() : nullptr; + if (doc) { + nsContentUtils::ReportToConsoleNonLocalized( + msg, nsIScriptError::warningFlag, "DOM"_ns, doc); + } else { + NS_WARNING("Failed to report l10n DOM Overlay errors to console."); + } + printf_stderr("%s\n", NS_ConvertUTF16toUTF8(msg).get()); + } + } +} + +void DOMLocalization::ConvertStringToL10nArgs(const nsString& aInput, + intl::L10nArgs& aRetVal, + ErrorResult& aRv) { + if (aInput.IsEmpty()) { + // There are no properties. + return; + } + // This method uses a temporary dictionary to automate + // converting a JSON string into an IDL Record via a dictionary. + // + // Once we get Record::Init(const nsAString& aJSON), we'll switch to + // that. + L10nArgsHelperDict helperDict; + if (!helperDict.Init(u"{\"args\": "_ns + aInput + u"}"_ns)) { + nsTArray<nsCString> errors{ + "[dom/l10n] Failed to parse l10n-args JSON: "_ns + + NS_ConvertUTF16toUTF8(aInput), + }; + MaybeReportErrorsToGecko(errors, aRv, GetParentObject()); + return; + } + for (auto& entry : helperDict.mArgs.Entries()) { + L10nArgs::EntryType* newEntry = aRetVal.Entries().AppendElement(fallible); + if (!newEntry) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + newEntry->mKey = entry.mKey; + newEntry->mValue = entry.mValue; + } +} diff --git a/dom/l10n/DOMLocalization.h b/dom/l10n/DOMLocalization.h new file mode 100644 index 0000000000..ab66194d31 --- /dev/null +++ b/dom/l10n/DOMLocalization.h @@ -0,0 +1,137 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_l10n_DOMLocalization_h +#define mozilla_dom_l10n_DOMLocalization_h + +#include "nsTHashSet.h" +#include "nsXULPrototypeDocument.h" +#include "mozilla/intl/Localization.h" +#include "mozilla/dom/DOMLocalizationBinding.h" +#include "mozilla/dom/L10nMutations.h" +#include "mozilla/dom/L10nOverlaysBinding.h" +#include "mozilla/dom/LocalizationBinding.h" +#include "mozilla/dom/PromiseNativeHandler.h" +#include "mozilla/intl/L10nRegistry.h" + +// XXX Avoid including this here by moving function bodies to the cpp file +#include "nsINode.h" + +namespace mozilla::dom { + +class Element; +class L10nMutations; + +class DOMLocalization : public intl::Localization { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DOMLocalization, Localization) + + void Destroy(); + + static already_AddRefed<DOMLocalization> Constructor( + const dom::GlobalObject& aGlobal, + const dom::Sequence<dom::OwningUTF8StringOrResourceId>& aResourceIds, + bool aIsSync, + const dom::Optional<dom::NonNull<intl::L10nRegistry>>& aRegistry, + const dom::Optional<dom::Sequence<nsCString>>& aLocales, + ErrorResult& aRv); + + JSObject* WrapObject(JSContext*, JS::Handle<JSObject*> aGivenProto) override; + + bool HasPendingMutations() const; + + /** + * DOMLocalization API + * + * Methods documentation in DOMLocalization.webidl + */ + + void ConnectRoot(nsINode& aNode); + void DisconnectRoot(nsINode& aNode); + + void PauseObserving(); + void ResumeObserving(); + + void SetAttributes(JSContext* aCx, Element& aElement, const nsAString& aId, + const Optional<JS::Handle<JSObject*>>& aArgs, + ErrorResult& aRv); + void GetAttributes(Element& aElement, L10nIdArgs& aResult, ErrorResult& aRv); + + void SetArgs(JSContext* aCx, Element& aElement, + const Optional<JS::Handle<JSObject*>>& aArgs, ErrorResult& aRv); + + already_AddRefed<Promise> TranslateFragment(nsINode& aNode, ErrorResult& aRv); + + already_AddRefed<Promise> TranslateElements( + const nsTArray<OwningNonNull<Element>>& aElements, ErrorResult& aRv); + already_AddRefed<Promise> TranslateElements( + const nsTArray<OwningNonNull<Element>>& aElements, + nsXULPrototypeDocument* aProto, ErrorResult& aRv); + + already_AddRefed<Promise> TranslateRoots(ErrorResult& aRv); + + /** + * Helper methods + */ + + /** + * Accumulates all translatable elements (ones containing + * a `data-l10n-id` attribute) from under a node into + * a list of elements. + */ + static void GetTranslatables(nsINode& aNode, + Sequence<OwningNonNull<Element>>& aElements, + ErrorResult& aRv); + + /** + * Sets the root information such as locale and direction. + */ + static void SetRootInfo(Element* aElement); + + /** + * Applies l10n translations on translatable elements. + * + * If `aProto` gets passed, it'll be used to cache + * the localized elements. + * + * Result is `true` if all translations were applied + * successfully, and `false` otherwise. + */ + bool ApplyTranslations(nsTArray<nsCOMPtr<Element>>& aElements, + nsTArray<Nullable<L10nMessage>>& aTranslations, + nsXULPrototypeDocument* aProto, ErrorResult& aRv); + + bool SubtreeRootInRoots(nsINode* aSubtreeRoot) { + for (const auto* key : mRoots) { + nsINode* subtreeRoot = key->SubtreeRoot(); + if (subtreeRoot == aSubtreeRoot) { + return true; + } + } + return false; + } + + DOMLocalization(nsIGlobalObject* aGlobal, bool aSync); + DOMLocalization(nsIGlobalObject* aGlobal, bool aIsSync, + const intl::ffi::LocalizationRc* aRaw); + + protected: + virtual ~DOMLocalization(); + void OnChange() override; + void DisconnectMutations(); + void DisconnectRoots(); + void ReportL10nOverlaysErrors(nsTArray<L10nOverlaysError>& aErrors); + void ConvertStringToL10nArgs(const nsString& aInput, intl::L10nArgs& aRetVal, + ErrorResult& aRv); + + RefPtr<L10nMutations> mMutations; + nsTHashSet<RefPtr<nsINode>> mRoots; +}; + +} // namespace mozilla::dom + +#endif diff --git a/dom/l10n/DocumentL10n.cpp b/dom/l10n/DocumentL10n.cpp new file mode 100644 index 0000000000..828a0583e3 --- /dev/null +++ b/dom/l10n/DocumentL10n.cpp @@ -0,0 +1,347 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "DocumentL10n.h" +#include "nsIContentSink.h" +#include "nsContentUtils.h" +#include "mozilla/dom/AutoEntryScript.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentL10nBinding.h" +#include "mozilla/Telemetry.h" + +using namespace mozilla; +using namespace mozilla::intl; +using namespace mozilla::dom; + +NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentL10n) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentL10n, DOMLocalization) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mReady) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mContentSink) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentL10n, DOMLocalization) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mReady) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mContentSink) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_ADDREF_INHERITED(DocumentL10n, DOMLocalization) +NS_IMPL_RELEASE_INHERITED(DocumentL10n, DOMLocalization) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentL10n) +NS_INTERFACE_MAP_END_INHERITING(DOMLocalization) + +bool DocumentL10n::mIsFirstBrowserWindow = true; + +/* static */ +RefPtr<DocumentL10n> DocumentL10n::Create(Document* aDocument, bool aSync) { + RefPtr<DocumentL10n> l10n = new DocumentL10n(aDocument, aSync); + + IgnoredErrorResult rv; + l10n->mReady = Promise::Create(l10n->mGlobal, rv); + if (NS_WARN_IF(rv.Failed())) { + return nullptr; + } + + return l10n.forget(); +} + +DocumentL10n::DocumentL10n(Document* aDocument, bool aSync) + : DOMLocalization(aDocument->GetScopeObject(), aSync), + mDocument(aDocument), + mState(DocumentL10nState::Constructed) { + mContentSink = do_QueryInterface(aDocument->GetCurrentContentSink()); +} + +JSObject* DocumentL10n::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return DocumentL10n_Binding::Wrap(aCx, this, aGivenProto); +} + +class L10nReadyHandler final : public PromiseNativeHandler { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(L10nReadyHandler) + + explicit L10nReadyHandler(Promise* aPromise, DocumentL10n* aDocumentL10n) + : mPromise(aPromise), mDocumentL10n(aDocumentL10n) {} + + void ResolvedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + mDocumentL10n->InitialTranslationCompleted(true); + mPromise->MaybeResolveWithUndefined(); + } + + void RejectedCallback(JSContext* aCx, JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + mDocumentL10n->InitialTranslationCompleted(false); + + nsTArray<nsCString> errors{ + "[dom/l10n] Could not complete initial document translation."_ns, + }; + IgnoredErrorResult rv; + MaybeReportErrorsToGecko(errors, rv, mDocumentL10n->GetParentObject()); + + /** + * We resolve the mReady here even if we encountered failures, because + * there is nothing actionable for the user pending on `mReady` to do here + * and we don't want to incentivized consumers of this API to plan the + * same pending operation for resolve and reject scenario. + * + * Additionally, without it, the stderr received "uncaught promise + * rejection" warning, which is noisy and not-actionable. + * + * So instead, we just resolve and report errors. + */ + mPromise->MaybeResolveWithUndefined(); + } + + private: + ~L10nReadyHandler() = default; + + RefPtr<Promise> mPromise; + RefPtr<DocumentL10n> mDocumentL10n; +}; + +NS_IMPL_CYCLE_COLLECTION(L10nReadyHandler, mPromise, mDocumentL10n) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nReadyHandler) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nReadyHandler) +NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nReadyHandler) + +void DocumentL10n::TriggerInitialTranslation() { + MOZ_ASSERT(nsContentUtils::IsSafeToRunScript()); + if (mState >= DocumentL10nState::InitialTranslationTriggered) { + return; + } + if (!mReady) { + // If we don't have `mReady` it means that we are in shutdown mode. + // See bug 1687118 for details. + InitialTranslationCompleted(false); + return; + } + + mInitialTranslationStart = mozilla::TimeStamp::Now(); + + AutoAllowLegacyScriptExecution exemption; + + nsTArray<RefPtr<Promise>> promises; + + ErrorResult rv; + promises.AppendElement(TranslateDocument(rv)); + if (NS_WARN_IF(rv.Failed())) { + rv.SuppressException(); + InitialTranslationCompleted(false); + mReady->MaybeRejectWithUndefined(); + return; + } + promises.AppendElement(TranslateRoots(rv)); + Element* documentElement = mDocument->GetDocumentElement(); + if (!documentElement) { + InitialTranslationCompleted(false); + mReady->MaybeRejectWithUndefined(); + return; + } + + DOMLocalization::ConnectRoot(*documentElement); + + AutoEntryScript aes(mGlobal, "DocumentL10n InitialTranslation"); + RefPtr<Promise> promise = Promise::All(aes.cx(), promises, rv); + + if (promise->State() == Promise::PromiseState::Resolved) { + // If the promise is already resolved, we can fast-track + // to initial translation completed. + InitialTranslationCompleted(true); + mReady->MaybeResolveWithUndefined(); + } else { + RefPtr<PromiseNativeHandler> l10nReadyHandler = + new L10nReadyHandler(mReady, this); + promise->AppendNativeHandler(l10nReadyHandler); + + mState = DocumentL10nState::InitialTranslationTriggered; + } +} + +already_AddRefed<Promise> DocumentL10n::TranslateDocument(ErrorResult& aRv) { + MOZ_ASSERT(mState == DocumentL10nState::Constructed, + "This method should be called only from Constructed state."); + RefPtr<Promise> promise = Promise::Create(mGlobal, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + Element* elem = mDocument->GetDocumentElement(); + if (!elem) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + + // 1. Collect all localizable elements. + Sequence<OwningNonNull<Element>> elements; + GetTranslatables(*elem, elements, aRv); + if (NS_WARN_IF(aRv.Failed())) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + + RefPtr<nsXULPrototypeDocument> proto = mDocument->GetPrototype(); + + // 2. Check if the document has a prototype that may cache + // translated elements. + if (proto) { + // 2.1. Handle the case when we have proto. + + // 2.1.1. Move elements that are not in the proto to a separate + // array. + Sequence<OwningNonNull<Element>> nonProtoElements; + + uint32_t i = elements.Length(); + while (i > 0) { + Element* elem = elements.ElementAt(i - 1); + MOZ_RELEASE_ASSERT(elem->HasAttr(nsGkAtoms::datal10nid)); + if (!elem->HasElementCreatedFromPrototypeAndHasUnmodifiedL10n()) { + if (NS_WARN_IF(!nonProtoElements.AppendElement(*elem, fallible))) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + elements.RemoveElement(elem); + } + i--; + } + + // We populate the sequence in reverse order. Let's bring it + // back to top->bottom one. + nonProtoElements.Reverse(); + + AutoAllowLegacyScriptExecution exemption; + + nsTArray<RefPtr<Promise>> promises; + + // 2.1.2. If we're not loading from cache, push the elements that + // are in the prototype to be translated and cached. + if (!proto->WasL10nCached() && !elements.IsEmpty()) { + RefPtr<Promise> translatePromise = + TranslateElements(elements, proto, aRv); + if (NS_WARN_IF(!translatePromise || aRv.Failed())) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + promises.AppendElement(translatePromise); + } + + // 2.1.3. If there are elements that are not in the prototype, + // localize them without attempting to cache and + // independently of if we're loading from cache. + if (!nonProtoElements.IsEmpty()) { + RefPtr<Promise> nonProtoTranslatePromise = + TranslateElements(nonProtoElements, nullptr, aRv); + if (NS_WARN_IF(!nonProtoTranslatePromise || aRv.Failed())) { + promise->MaybeRejectWithUndefined(); + return promise.forget(); + } + promises.AppendElement(nonProtoTranslatePromise); + } + + // 2.1.4. Collect promises with Promise::All (maybe empty). + AutoEntryScript aes(mGlobal, "DocumentL10n InitialTranslationCompleted"); + promise = Promise::All(aes.cx(), promises, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + } else { + // 2.2. Handle the case when we don't have proto. + + // 2.2.1. Otherwise, translate all available elements, + // without attempting to cache them. + promise = TranslateElements(elements, nullptr, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + } + + return promise.forget(); +} + +void DocumentL10n::MaybeRecordTelemetry() { + mozilla::TimeStamp initialTranslationEnd = mozilla::TimeStamp::Now(); + + nsAutoString documentURI; + ErrorResult rv; + rv = mDocument->GetDocumentURI(documentURI); + if (rv.Failed()) { + return; + } + + nsCString key; + + if (documentURI.Find(u"chrome://browser/content/browser.xhtml") == 0) { + if (mIsFirstBrowserWindow) { + key = "browser_first_window"; + mIsFirstBrowserWindow = false; + } else { + key = "browser_new_window"; + } + } else if (documentURI.Find(u"about:home") == 0) { + key = "about:home"; + } else if (documentURI.Find(u"about:newtab") == 0) { + key = "about:newtab"; + } else if (documentURI.Find(u"about:preferences") == 0) { + key = "about:preferences"; + } else { + return; + } + + mozilla::TimeDuration totalTime(initialTranslationEnd - + mInitialTranslationStart); + Accumulate(Telemetry::L10N_DOCUMENT_INITIAL_TRANSLATION_TIME_US, key, + totalTime.ToMicroseconds()); +} + +void DocumentL10n::InitialTranslationCompleted(bool aL10nCached) { + if (mState >= DocumentL10nState::Ready) { + return; + } + + Element* documentElement = mDocument->GetDocumentElement(); + if (documentElement) { + SetRootInfo(documentElement); + } + + mState = DocumentL10nState::Ready; + + MaybeRecordTelemetry(); + + RefPtr<Document> doc = mDocument; + doc->InitialTranslationCompleted(aL10nCached); + + // In XUL scenario contentSink is nullptr. + if (mContentSink) { + nsCOMPtr<nsIContentSink> sink = mContentSink.forget(); + sink->InitialTranslationCompleted(); + } + + // From now on, the state of Localization is unconditionally + // async. + SetAsync(); +} + +void DocumentL10n::ConnectRoot(nsINode& aNode, bool aTranslate, + ErrorResult& aRv) { + if (aTranslate) { + if (mState >= DocumentL10nState::InitialTranslationTriggered) { + RefPtr<Promise> promise = TranslateFragment(aNode, aRv); + } + } + DOMLocalization::ConnectRoot(aNode); +} + +Promise* DocumentL10n::Ready() { return mReady; } + +void DocumentL10n::OnCreatePresShell() { mMutations->OnCreatePresShell(); } diff --git a/dom/l10n/DocumentL10n.h b/dom/l10n/DocumentL10n.h new file mode 100644 index 0000000000..e7b2fd5189 --- /dev/null +++ b/dom/l10n/DocumentL10n.h @@ -0,0 +1,88 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cindent: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_l10n_DocumentL10n_h +#define mozilla_dom_l10n_DocumentL10n_h + +#include "mozilla/dom/DOMLocalization.h" + +class nsIContentSink; + +namespace mozilla::dom { + +class Document; + +enum class DocumentL10nState { + // State set when the DocumentL10n gets constructed. + Constructed = 0, + + // State set when the initial translation got triggered. This happens + // if DocumentL10n was constructed during parsing of the document. + // + // If the DocumentL10n gets constructed later, we'll skip directly to + // Ready state. + InitialTranslationTriggered, + + // State set the DocumentL10n has been fully initialized, potentially + // with initial translation being completed. + Ready, +}; + +/** + * This class maintains localization status of the document. + * + * The document will initialize it lazily when a link with a localization + * resource is added to the document. + * + * Once initialized, DocumentL10n relays all API methods to an + * instance of mozILocalization and maintains a single promise + * which gets resolved the first time the document gets translated. + */ +class DocumentL10n final : public DOMLocalization { + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DocumentL10n, DOMLocalization) + + static RefPtr<DocumentL10n> Create(Document* aDocument, bool aSync); + + protected: + explicit DocumentL10n(Document* aDocument, bool aSync); + virtual ~DocumentL10n() = default; + + RefPtr<Document> mDocument; + RefPtr<Promise> mReady; + DocumentL10nState mState; + nsCOMPtr<nsIContentSink> mContentSink; + + public: + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + Promise* Ready(); + + void TriggerInitialTranslation(); + already_AddRefed<Promise> TranslateDocument(ErrorResult& aRv); + + void InitialTranslationCompleted(bool aL10nCached); + + Document* GetDocument() const { return mDocument; }; + void OnCreatePresShell(); + + void ConnectRoot(nsINode& aNode, bool aTranslate, ErrorResult& aRv); + + DocumentL10nState GetState() { return mState; }; + + void MaybeRecordTelemetry(); + + bool mBlockingLayout = false; + + mozilla::TimeStamp mInitialTranslationStart; + static bool mIsFirstBrowserWindow; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_l10n_DocumentL10n_h diff --git a/dom/l10n/L10nMutations.cpp b/dom/l10n/L10nMutations.cpp new file mode 100644 index 0000000000..63e30c2d83 --- /dev/null +++ b/dom/l10n/L10nMutations.cpp @@ -0,0 +1,362 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "L10nMutations.h" +#include "mozilla/dom/DocumentInlines.h" +#include "nsRefreshDriver.h" +#include "DOMLocalization.h" +#include "mozilla/intl/Localization.h" +#include "nsThreadManager.h" + +using namespace mozilla; +using namespace mozilla::intl; +using namespace mozilla::dom; + +NS_IMPL_CYCLE_COLLECTION_CLASS(L10nMutations) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(L10nMutations) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mPendingElements) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mPendingElementsHash) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(L10nMutations) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPendingElements) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPendingElementsHash) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nMutations) + NS_INTERFACE_MAP_ENTRY(nsIMutationObserver) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nMutations) +NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nMutations) + +L10nMutations::L10nMutations(DOMLocalization* aDOMLocalization) + : mDOMLocalization(aDOMLocalization) { + mObserving = true; +} + +L10nMutations::~L10nMutations() { + StopRefreshObserver(); + MOZ_ASSERT(!mDOMLocalization, + "DOMLocalization<-->L10nMutations cycle should be broken."); +} + +void L10nMutations::AttributeChanged(Element* aElement, int32_t aNameSpaceID, + nsAtom* aAttribute, int32_t aModType, + const nsAttrValue* aOldValue) { + if (!mObserving) { + return; + } + + if (aNameSpaceID == kNameSpaceID_None && + (aAttribute == nsGkAtoms::datal10nid || + aAttribute == nsGkAtoms::datal10nargs)) { + if (IsInRoots(aElement)) { + L10nElementChanged(aElement); + } + } +} + +void L10nMutations::ContentAppended(nsIContent* aChild) { + if (!mObserving) { + return; + } + + if (!IsInRoots(aChild)) { + return; + } + + Sequence<OwningNonNull<Element>> elements; + for (nsIContent* node = aChild; node; node = node->GetNextSibling()) { + if (node->IsElement()) { + DOMLocalization::GetTranslatables(*node, elements, IgnoreErrors()); + } + } + + for (auto& elem : elements) { + L10nElementChanged(elem); + } +} + +void L10nMutations::ContentInserted(nsIContent* aChild) { + if (!mObserving) { + return; + } + + if (!aChild->IsElement()) { + return; + } + Element* elem = aChild->AsElement(); + + if (!IsInRoots(elem)) { + return; + } + + Sequence<OwningNonNull<Element>> elements; + DOMLocalization::GetTranslatables(*aChild, elements, IgnoreErrors()); + + for (auto& elem : elements) { + L10nElementChanged(elem); + } +} + +void L10nMutations::ContentRemoved(nsIContent* aChild, + nsIContent* aPreviousSibling) { + if (!mObserving || mPendingElements.IsEmpty()) { + return; + } + + Element* elem = Element::FromNode(*aChild); + if (!elem || !IsInRoots(elem)) { + return; + } + + Sequence<OwningNonNull<Element>> elements; + DOMLocalization::GetTranslatables(*aChild, elements, IgnoreErrors()); + + for (auto& elem : elements) { + if (mPendingElementsHash.EnsureRemoved(elem)) { + mPendingElements.RemoveElement(elem); + } + } + + if (!HasPendingMutations()) { + nsContentUtils::AddScriptRunner(NewRunnableMethod( + "MaybeFirePendingTranslationsFinished", this, + &L10nMutations::MaybeFirePendingTranslationsFinished)); + } +} + +void L10nMutations::L10nElementChanged(Element* aElement) { + const bool wasEmpty = mPendingElements.IsEmpty(); + + if (mPendingElementsHash.EnsureInserted(aElement)) { + mPendingElements.AppendElement(aElement); + } + + if (!wasEmpty) { + return; + } + + if (!mRefreshDriver) { + StartRefreshObserver(); + } + + if (!mBlockingLoad) { + Document* doc = GetDocument(); + if (doc && doc->GetReadyStateEnum() != Document::READYSTATE_COMPLETE) { + doc->BlockOnload(); + mBlockingLoad = true; + } + } + + if (mBlockingLoad && !mPendingBlockingLoadFlush) { + // We want to make sure we flush translations and don't block the load + // indefinitely (and, in fact, that we do it rather soon, even if the + // refresh driver is not ticking yet). + // + // In some platforms (mainly Wayland) the load of the main document + // causes vsync to start running and start ticking the refresh driver, + // so we can't rely on the refresh driver ticking yet. + RefPtr<nsIRunnable> task = + NewRunnableMethod("FlushPendingTranslationsBeforeLoad", this, + &L10nMutations::FlushPendingTranslationsBeforeLoad); + nsThreadManager::get().DispatchDirectTaskToCurrentThread(task); + mPendingBlockingLoadFlush = true; + } +} + +void L10nMutations::PauseObserving() { mObserving = false; } + +void L10nMutations::ResumeObserving() { mObserving = true; } + +void L10nMutations::WillRefresh(mozilla::TimeStamp aTime) { + StopRefreshObserver(); + FlushPendingTranslations(); +} + +/** + * The handler for the `TranslateElements` promise used to turn + * a potential rejection into a console warning. + **/ +class L10nMutationFinalizationHandler final : public PromiseNativeHandler { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(L10nMutationFinalizationHandler) + + explicit L10nMutationFinalizationHandler(L10nMutations* aMutations, + nsIGlobalObject* aGlobal) + : mMutations(aMutations), mGlobal(aGlobal) {} + + MOZ_CAN_RUN_SCRIPT void Settled() { + if (RefPtr mutations = mMutations) { + mutations->PendingPromiseSettled(); + } + } + + MOZ_CAN_RUN_SCRIPT void ResolvedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + Settled(); + } + + MOZ_CAN_RUN_SCRIPT void RejectedCallback(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ErrorResult& aRv) override { + nsTArray<nsCString> errors{ + "[dom/l10n] Errors during l10n mutation frame."_ns, + }; + MaybeReportErrorsToGecko(errors, IgnoreErrors(), mGlobal); + Settled(); + } + + private: + ~L10nMutationFinalizationHandler() = default; + + RefPtr<L10nMutations> mMutations; + nsCOMPtr<nsIGlobalObject> mGlobal; +}; + +NS_IMPL_CYCLE_COLLECTION(L10nMutationFinalizationHandler, mGlobal, mMutations) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(L10nMutationFinalizationHandler) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(L10nMutationFinalizationHandler) +NS_IMPL_CYCLE_COLLECTING_RELEASE(L10nMutationFinalizationHandler) + +void L10nMutations::FlushPendingTranslationsBeforeLoad() { + MOZ_ASSERT(mPendingBlockingLoadFlush); + mPendingBlockingLoadFlush = false; + FlushPendingTranslations(); +} + +void L10nMutations::FlushPendingTranslations() { + if (!mDOMLocalization) { + return; + } + + nsTArray<OwningNonNull<Element>> elements; + for (auto& elem : mPendingElements) { + if (elem->HasAttr(nsGkAtoms::datal10nid)) { + elements.AppendElement(*elem); + } + } + + mPendingElementsHash.Clear(); + mPendingElements.Clear(); + + RefPtr<Promise> promise = + mDOMLocalization->TranslateElements(elements, IgnoreErrors()); + if (promise && promise->State() == Promise::PromiseState::Pending) { + mPendingPromises++; + auto l10nMutationFinalizationHandler = + MakeRefPtr<L10nMutationFinalizationHandler>( + this, mDOMLocalization->GetParentObject()); + promise->AppendNativeHandler(l10nMutationFinalizationHandler); + } + + MaybeFirePendingTranslationsFinished(); +} + +void L10nMutations::PendingPromiseSettled() { + MOZ_DIAGNOSTIC_ASSERT(mPendingPromises); + mPendingPromises--; + MaybeFirePendingTranslationsFinished(); +} + +void L10nMutations::MaybeFirePendingTranslationsFinished() { + if (HasPendingMutations()) { + return; + } + + RefPtr doc = GetDocument(); + if (NS_WARN_IF(!doc)) { + return; + } + + if (mBlockingLoad) { + mBlockingLoad = false; + doc->UnblockOnload(false); + } + nsContentUtils::DispatchEventOnlyToChrome( + doc, ToSupports(doc), u"L10nMutationsFinished"_ns, CanBubble::eNo, + Cancelable::eNo, Composed::eNo, nullptr); +} + +void L10nMutations::Disconnect() { + StopRefreshObserver(); + mDOMLocalization = nullptr; +} + +Document* L10nMutations::GetDocument() const { + if (!mDOMLocalization) { + return nullptr; + } + auto* innerWindow = mDOMLocalization->GetParentObject()->AsInnerWindow(); + if (!innerWindow) { + return nullptr; + } + return innerWindow->GetExtantDoc(); +} + +void L10nMutations::StartRefreshObserver() { + if (!mDOMLocalization || mRefreshDriver) { + return; + } + if (Document* doc = GetDocument()) { + if (nsPresContext* ctx = doc->GetPresContext()) { + mRefreshDriver = ctx->RefreshDriver(); + } + } + + // If we can't start the refresh driver, it means + // that the presContext is not available yet. + // In that case, we'll trigger the flush of pending + // elements in Document::CreatePresShell. + if (mRefreshDriver) { + mRefreshDriver->AddRefreshObserver(this, FlushType::Style, + "L10n mutations"); + } else { + NS_WARNING("[l10n][mutations] Failed to start a refresh observer."); + } +} + +void L10nMutations::StopRefreshObserver() { + if (!mDOMLocalization) { + return; + } + + if (mRefreshDriver) { + mRefreshDriver->RemoveRefreshObserver(this, FlushType::Style); + mRefreshDriver = nullptr; + } +} + +void L10nMutations::OnCreatePresShell() { + if (!mPendingElements.IsEmpty()) { + StartRefreshObserver(); + } +} + +bool L10nMutations::IsInRoots(nsINode* aNode) { + // If the root of the mutated element is in the light DOM, + // we know it must be covered by our observer directly. + // + // Otherwise, we need to check if its subtree root is the same + // as any of the `DOMLocalization::mRoots` subtree roots. + nsINode* root = aNode->SubtreeRoot(); + + // If element is in light DOM, it must be covered by one of + // the DOMLocalization roots to end up here. + MOZ_ASSERT_IF(!root->IsShadowRoot(), + mDOMLocalization->SubtreeRootInRoots(root)); + + return !root->IsShadowRoot() || mDOMLocalization->SubtreeRootInRoots(root); +} diff --git a/dom/l10n/L10nMutations.h b/dom/l10n/L10nMutations.h new file mode 100644 index 0000000000..afc08445e0 --- /dev/null +++ b/dom/l10n/L10nMutations.h @@ -0,0 +1,101 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_l10n_L10nMutations_h +#define mozilla_dom_l10n_L10nMutations_h + +#include "nsCycleCollectionParticipant.h" +#include "nsHashKeys.h" +#include "nsRefreshObservers.h" +#include "nsStubMutationObserver.h" +#include "nsTHashSet.h" + +class nsRefreshDriver; + +namespace mozilla::dom { +class Document; +class DOMLocalization; +/** + * L10nMutations manage observing roots for localization + * changes and coalescing pending translations into + * batches - one per animation frame. + */ +class L10nMutations final : public nsStubMultiMutationObserver, + public nsARefreshObserver { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(L10nMutations, nsIMutationObserver) + NS_DECL_NSIMUTATIONOBSERVER_CONTENTAPPENDED + NS_DECL_NSIMUTATIONOBSERVER_CONTENTINSERTED + NS_DECL_NSIMUTATIONOBSERVER_CONTENTREMOVED + NS_DECL_NSIMUTATIONOBSERVER_ATTRIBUTECHANGED + + explicit L10nMutations(DOMLocalization* aDOMLocalization); + + /** + * Pause root observation. + * This is useful for injecting already-translated + * content into an observed root, without causing + * superflues translation. + */ + void PauseObserving(); + + /** + * Resume root observation. + */ + void ResumeObserving(); + + /** + * Disconnect roots, stop refresh observer + * and break the cycle collection deadlock + * by removing the reference to mDOMLocalization. + */ + void Disconnect(); + + /** + * Called when PresShell gets created for the document. + * If there are already pending mutations, this + * will schedule the refresh driver to translate them. + */ + void OnCreatePresShell(); + + bool HasPendingMutations() const { + return !mPendingElements.IsEmpty() || mPendingPromises; + } + + MOZ_CAN_RUN_SCRIPT void PendingPromiseSettled(); + + private: + bool mObserving = false; + bool mBlockingLoad = false; + bool mPendingBlockingLoadFlush = false; + uint32_t mPendingPromises = 0; + RefPtr<nsRefreshDriver> mRefreshDriver; + DOMLocalization* mDOMLocalization; + + // The hash is used to speed up lookups into mPendingElements, which we need + // to guarantee some consistent ordering of operations. + nsTHashSet<RefPtr<Element>> mPendingElementsHash; + nsTArray<RefPtr<Element>> mPendingElements; + + Document* GetDocument() const; + + MOZ_CAN_RUN_SCRIPT void WillRefresh(mozilla::TimeStamp aTime) override; + + void StartRefreshObserver(); + void StopRefreshObserver(); + void L10nElementChanged(Element* aElement); + MOZ_CAN_RUN_SCRIPT void FlushPendingTranslations(); + MOZ_CAN_RUN_SCRIPT void FlushPendingTranslationsBeforeLoad(); + MOZ_CAN_RUN_SCRIPT void MaybeFirePendingTranslationsFinished(); + + ~L10nMutations(); + bool IsInRoots(nsINode* aNode); +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_l10n_L10nMutations_h__ diff --git a/dom/l10n/L10nOverlays.cpp b/dom/l10n/L10nOverlays.cpp new file mode 100644 index 0000000000..716479b381 --- /dev/null +++ b/dom/l10n/L10nOverlays.cpp @@ -0,0 +1,557 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "L10nOverlays.h" +#include "mozilla/dom/HTMLTemplateElement.h" +#include "mozilla/dom/HTMLInputElement.h" +#include "HTMLSplitOnSpacesTokenizer.h" +#include "nsHtml5StringParser.h" +#include "nsTextNode.h" +#include "nsIParserUtils.h" + +using namespace mozilla::dom; +using namespace mozilla; + +/** + * Check if attribute is allowed for the given element. + * + * This method is used by the sanitizer when the translation markup contains DOM + * attributes, or when the translation has traits which map to DOM attributes. + * + * `aExplicitlyAllowed` can be passed as a list of attributes explicitly allowed + * on this element. + */ +static bool IsAttrNameLocalizable( + const nsAtom* aAttrName, Element* aElement, + const nsTArray<nsString>& aExplicitlyAllowed) { + if (!aExplicitlyAllowed.IsEmpty()) { + nsAutoString name; + aAttrName->ToString(name); + if (aExplicitlyAllowed.Contains(name)) { + return true; + } + } + + nsAtom* elemName = aElement->NodeInfo()->NameAtom(); + uint32_t nameSpace = aElement->NodeInfo()->NamespaceID(); + + if (nameSpace == kNameSpaceID_XHTML) { + // Is it a globally safe attribute? + if (aAttrName == nsGkAtoms::title || aAttrName == nsGkAtoms::aria_label || + aAttrName == nsGkAtoms::aria_description) { + return true; + } + + // Is it allowed on this element? + if (elemName == nsGkAtoms::a) { + return aAttrName == nsGkAtoms::download; + } + if (elemName == nsGkAtoms::area) { + return aAttrName == nsGkAtoms::download || aAttrName == nsGkAtoms::alt; + } + if (elemName == nsGkAtoms::input) { + // Special case for value on HTML inputs with type button, reset, submit + if (aAttrName == nsGkAtoms::value) { + HTMLInputElement* input = HTMLInputElement::FromNode(aElement); + if (input) { + auto type = input->ControlType(); + if (type == FormControlType::InputSubmit || + type == FormControlType::InputButton || + type == FormControlType::InputReset) { + return true; + } + } + } + return aAttrName == nsGkAtoms::alt || aAttrName == nsGkAtoms::placeholder; + } + if (elemName == nsGkAtoms::menuitem) { + return aAttrName == nsGkAtoms::label; + } + if (elemName == nsGkAtoms::menu) { + return aAttrName == nsGkAtoms::label; + } + if (elemName == nsGkAtoms::optgroup) { + return aAttrName == nsGkAtoms::label; + } + if (elemName == nsGkAtoms::option) { + return aAttrName == nsGkAtoms::label; + } + if (elemName == nsGkAtoms::track) { + return aAttrName == nsGkAtoms::label; + } + if (elemName == nsGkAtoms::img) { + return aAttrName == nsGkAtoms::alt; + } + if (elemName == nsGkAtoms::textarea) { + return aAttrName == nsGkAtoms::placeholder; + } + if (elemName == nsGkAtoms::th) { + return aAttrName == nsGkAtoms::abbr; + } + + } else if (nameSpace == kNameSpaceID_XUL) { + // Is it a globally safe attribute? + if (aAttrName == nsGkAtoms::accesskey || + aAttrName == nsGkAtoms::aria_label || aAttrName == nsGkAtoms::label || + aAttrName == nsGkAtoms::title || aAttrName == nsGkAtoms::tooltiptext) { + return true; + } + + // Is it allowed on this element? + if (elemName == nsGkAtoms::description) { + return aAttrName == nsGkAtoms::value; + } + if (elemName == nsGkAtoms::key) { + return aAttrName == nsGkAtoms::key || aAttrName == nsGkAtoms::keycode; + } + if (elemName == nsGkAtoms::label) { + return aAttrName == nsGkAtoms::value; + } + } + + return false; +} + +already_AddRefed<nsINode> L10nOverlays::CreateTextNodeFromTextContent( + Element* aElement, ErrorResult& aRv) { + nsAutoString content; + aElement->GetTextContent(content, aRv); + + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + return aElement->OwnerDoc()->CreateTextNode(content); +} + +class AttributeNameValueComparator { + public: + bool Equals(const AttributeNameValue& aAttribute, + const nsAttrName* aAttrName) const { + return aAttrName->Equals(NS_ConvertUTF8toUTF16(aAttribute.mName)); + } +}; + +void L10nOverlays::OverlayAttributes( + const Nullable<Sequence<AttributeNameValue>>& aTranslation, + Element* aToElement, ErrorResult& aRv) { + nsTArray<nsString> explicitlyAllowed; + + { + nsAutoString l10nAttrs; + if (aToElement->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nattrs, + l10nAttrs)) { + HTMLSplitOnSpacesTokenizer tokenizer(l10nAttrs, ','); + while (tokenizer.hasMoreTokens()) { + const nsAString& token = tokenizer.nextToken(); + if (!token.IsEmpty() && !explicitlyAllowed.Contains(token)) { + explicitlyAllowed.AppendElement(token); + } + } + } + } + + uint32_t i = aToElement->GetAttrCount(); + while (i > 0) { + const nsAttrName* attrName = aToElement->GetAttrNameAt(i - 1); + + if (IsAttrNameLocalizable(attrName->LocalName(), aToElement, + explicitlyAllowed) && + (aTranslation.IsNull() || + !aTranslation.Value().Contains(attrName, + AttributeNameValueComparator()))) { + RefPtr<nsAtom> localName = attrName->LocalName(); + aToElement->UnsetAttr(localName, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } + i--; + } + + if (aTranslation.IsNull()) { + return; + } + + for (auto& attribute : aTranslation.Value()) { + RefPtr<nsAtom> nameAtom = NS_Atomize(attribute.mName); + if (IsAttrNameLocalizable(nameAtom, aToElement, explicitlyAllowed)) { + NS_ConvertUTF8toUTF16 value(attribute.mValue); + if (!aToElement->AttrValueIs(kNameSpaceID_None, nameAtom, value, + eCaseMatters)) { + aToElement->SetAttr(nameAtom, value, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } + } + } +} + +void L10nOverlays::OverlayAttributes(Element* aFromElement, Element* aToElement, + ErrorResult& aRv) { + Nullable<Sequence<AttributeNameValue>> attributes; + uint32_t attrCount = aFromElement->GetAttrCount(); + + if (attrCount == 0) { + attributes.SetNull(); + } else { + Sequence<AttributeNameValue> sequence; + + uint32_t i = 0; + while (BorrowedAttrInfo info = aFromElement->GetAttrInfoAt(i++)) { + AttributeNameValue* attr = sequence.AppendElement(fallible); + MOZ_ASSERT(info.mName->NamespaceEquals(kNameSpaceID_None), + "No namespaced attributes allowed."); + info.mName->LocalName()->ToUTF8String(attr->mName); + + nsAutoString value; + info.mValue->ToString(value); + attr->mValue.Assign(NS_ConvertUTF16toUTF8(value)); + } + + attributes.SetValue(sequence); + } + + return OverlayAttributes(attributes, aToElement, aRv); +} + +void L10nOverlays::ShallowPopulateUsing(Element* aFromElement, + Element* aToElement, ErrorResult& aRv) { + nsAutoString content; + aFromElement->GetTextContent(content, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + aToElement->SetTextContent(content, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + OverlayAttributes(aFromElement, aToElement, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } +} + +already_AddRefed<nsINode> L10nOverlays::GetNodeForNamedElement( + Element* aSourceElement, Element* aTranslatedChild, + nsTArray<L10nOverlaysError>& aErrors, ErrorResult& aRv) { + nsAutoString childName; + aTranslatedChild->GetAttr(kNameSpaceID_None, nsGkAtoms::datal10nname, + childName); + RefPtr<Element> sourceChild = nullptr; + + nsINodeList* childNodes = aSourceElement->ChildNodes(); + for (uint32_t i = 0; i < childNodes->Length(); i++) { + nsINode* childNode = childNodes->Item(i); + + if (!childNode->IsElement()) { + continue; + } + Element* childElement = childNode->AsElement(); + + if (childElement->AttrValueIs(kNameSpaceID_None, nsGkAtoms::datal10nname, + childName, eCaseMatters)) { + sourceChild = childElement; + break; + } + } + + if (!sourceChild) { + L10nOverlaysError error; + error.mCode.Construct(L10nOverlays_Binding::ERROR_NAMED_ELEMENT_MISSING); + error.mL10nName.Construct(childName); + aErrors.AppendElement(error); + return CreateTextNodeFromTextContent(aTranslatedChild, aRv); + } + + nsAtom* sourceChildName = sourceChild->NodeInfo()->NameAtom(); + nsAtom* translatedChildName = aTranslatedChild->NodeInfo()->NameAtom(); + if (sourceChildName != translatedChildName && + // Create a specific exception for img vs. image mismatches, + // see bug 1543493 + !(translatedChildName == nsGkAtoms::img && + sourceChildName == nsGkAtoms::image)) { + L10nOverlaysError error; + error.mCode.Construct( + L10nOverlays_Binding::ERROR_NAMED_ELEMENT_TYPE_MISMATCH); + error.mL10nName.Construct(childName); + error.mTranslatedElementName.Construct( + aTranslatedChild->NodeInfo()->LocalName()); + error.mSourceElementName.Construct(sourceChild->NodeInfo()->LocalName()); + aErrors.AppendElement(error); + return CreateTextNodeFromTextContent(aTranslatedChild, aRv); + } + + aSourceElement->RemoveChild(*sourceChild, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + RefPtr<nsINode> clone = sourceChild->CloneNode(false, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + ShallowPopulateUsing(aTranslatedChild, clone->AsElement(), aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + return clone.forget(); +} + +bool L10nOverlays::IsElementAllowed(Element* aElement) { + uint32_t nameSpace = aElement->NodeInfo()->NamespaceID(); + if (nameSpace != kNameSpaceID_XHTML) { + return false; + } + + nsAtom* nameAtom = aElement->NodeInfo()->NameAtom(); + + return nameAtom == nsGkAtoms::em || nameAtom == nsGkAtoms::strong || + nameAtom == nsGkAtoms::small || nameAtom == nsGkAtoms::s || + nameAtom == nsGkAtoms::cite || nameAtom == nsGkAtoms::q || + nameAtom == nsGkAtoms::dfn || nameAtom == nsGkAtoms::abbr || + nameAtom == nsGkAtoms::data || nameAtom == nsGkAtoms::time || + nameAtom == nsGkAtoms::code || nameAtom == nsGkAtoms::var || + nameAtom == nsGkAtoms::samp || nameAtom == nsGkAtoms::kbd || + nameAtom == nsGkAtoms::sub || nameAtom == nsGkAtoms::sup || + nameAtom == nsGkAtoms::i || nameAtom == nsGkAtoms::b || + nameAtom == nsGkAtoms::u || nameAtom == nsGkAtoms::mark || + nameAtom == nsGkAtoms::bdi || nameAtom == nsGkAtoms::bdo || + nameAtom == nsGkAtoms::span || nameAtom == nsGkAtoms::br || + nameAtom == nsGkAtoms::wbr; +} + +already_AddRefed<Element> L10nOverlays::CreateSanitizedElement( + Element* aElement, ErrorResult& aRv) { + // Start with an empty element of the same type to remove nested children + // and non-localizable attributes defined by the translation. + + nsAutoString nameSpaceURI; + aElement->NodeInfo()->GetNamespaceURI(nameSpaceURI); + ElementCreationOptionsOrString options; + RefPtr<Element> clone = aElement->OwnerDoc()->CreateElementNS( + nameSpaceURI, aElement->NodeInfo()->LocalName(), options, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + ShallowPopulateUsing(aElement, clone, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + return clone.forget(); +} + +void L10nOverlays::OverlayChildNodes(DocumentFragment* aFromFragment, + Element* aToElement, + nsTArray<L10nOverlaysError>& aErrors, + ErrorResult& aRv) { + nsINodeList* childNodes = aFromFragment->ChildNodes(); + for (uint32_t i = 0; i < childNodes->Length(); i++) { + nsINode* childNode = childNodes->Item(i); + + if (!childNode->IsElement()) { + // Keep the translated text node. + continue; + } + + RefPtr<Element> childElement = childNode->AsElement(); + + if (childElement->HasAttr(kNameSpaceID_None, nsGkAtoms::datal10nname)) { + RefPtr<nsINode> sanitized = + GetNodeForNamedElement(aToElement, childElement, aErrors, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + aFromFragment->ReplaceChild(*sanitized, *childNode, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + continue; + } + + if (IsElementAllowed(childElement)) { + RefPtr<Element> sanitized = CreateSanitizedElement(childElement, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + aFromFragment->ReplaceChild(*sanitized, *childNode, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + continue; + } + + L10nOverlaysError error; + error.mCode.Construct(L10nOverlays_Binding::ERROR_FORBIDDEN_TYPE); + error.mTranslatedElementName.Construct( + childElement->NodeInfo()->LocalName()); + aErrors.AppendElement(error); + + // If all else fails, replace the element with its text content. + RefPtr<nsINode> textNode = CreateTextNodeFromTextContent(childElement, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + aFromFragment->ReplaceChild(*textNode, *childNode, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } + + while (aToElement->HasChildren()) { + nsIContent* child = aToElement->GetLastChild(); +#ifdef DEBUG + if (child->IsElement()) { + if (child->AsElement()->HasAttr(kNameSpaceID_None, + nsGkAtoms::datal10nid)) { + L10nOverlaysError error; + error.mCode.Construct( + L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISCONNECTED); + nsAutoString id; + child->AsElement()->GetAttr(nsGkAtoms::datal10nid, id); + error.mL10nName.Construct(id); + error.mTranslatedElementName.Construct( + aToElement->NodeInfo()->LocalName()); + aErrors.AppendElement(error); + } else if (child->AsElement()->ChildElementCount() > 0) { + L10nOverlaysError error; + error.mCode.Construct( + L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM); + nsAutoString id; + aToElement->GetAttr(nsGkAtoms::datal10nid, id); + error.mL10nName.Construct(id); + error.mTranslatedElementName.Construct( + aToElement->NodeInfo()->LocalName()); + aErrors.AppendElement(error); + } + } +#endif + aToElement->RemoveChildNode(child, true); + } + aToElement->AppendChild(*aFromFragment, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } +} + +void L10nOverlays::TranslateElement( + const GlobalObject& aGlobal, Element& aElement, + const L10nMessage& aTranslation, + Nullable<nsTArray<L10nOverlaysError>>& aErrors) { + nsTArray<L10nOverlaysError> errors; + + ErrorResult rv; + + TranslateElement(aElement, aTranslation, errors, rv); + if (NS_WARN_IF(rv.Failed())) { + L10nOverlaysError error; + error.mCode.Construct(L10nOverlays_Binding::ERROR_UNKNOWN); + errors.AppendElement(error); + } + if (!errors.IsEmpty()) { + aErrors.SetValue(std::move(errors)); + } +} + +bool L10nOverlays::ContainsMarkup(const nsACString& aStr) { + // We use our custom ContainsMarkup rather than the + // one from FragmentOrElement.cpp, because we don't + // want to trigger HTML parsing on every `Preferences & Options` + // type of string. + const char* start = aStr.BeginReading(); + const char* end = aStr.EndReading(); + + while (start != end) { + char c = *start; + if (c == '<') { + return true; + } + ++start; + + if (c == '&' && start != end) { + c = *start; + if (c == '#' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z')) { + return true; + } + ++start; + } + } + + return false; +} + +void L10nOverlays::TranslateElement(Element& aElement, + const L10nMessage& aTranslation, + nsTArray<L10nOverlaysError>& aErrors, + ErrorResult& aRv) { + if (!aTranslation.mValue.IsVoid()) { + NodeInfo* nodeInfo = aElement.NodeInfo(); + if (nodeInfo->NameAtom() == nsGkAtoms::title && + nodeInfo->NamespaceID() == kNameSpaceID_XHTML) { + // A special case for the HTML title element whose content must be text. + aElement.SetTextContent(NS_ConvertUTF8toUTF16(aTranslation.mValue), aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } else if (!ContainsMarkup(aTranslation.mValue)) { +#ifdef DEBUG + if (aElement.ChildElementCount() > 0) { + L10nOverlaysError error; + error.mCode.Construct( + L10nOverlays_Binding::ERROR_TRANSLATED_ELEMENT_DISALLOWED_DOM); + nsAutoString id; + aElement.GetAttr(nsGkAtoms::datal10nid, id); + error.mL10nName.Construct(id); + error.mTranslatedElementName.Construct( + aElement.GetLastElementChild()->NodeInfo()->LocalName()); + aErrors.AppendElement(error); + } +#endif + // If the translation doesn't contain any markup skip the overlay logic. + aElement.SetTextContent(NS_ConvertUTF8toUTF16(aTranslation.mValue), aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } else { + // Else parse the translation's HTML into a DocumentFragment, + // sanitize it and replace the element's content. + RefPtr<DocumentFragment> fragment = + new (aElement.OwnerDoc()->NodeInfoManager()) + DocumentFragment(aElement.OwnerDoc()->NodeInfoManager()); + // Note: these flags should be no less restrictive than the ones in + // nsContentUtils::ParseFragmentHTML . + // We supply the flags here because otherwise the parsing of HTML can + // trip DEBUG-only crashes, see bug 1809902 for details. + auto sanitizationFlags = nsIParserUtils::SanitizerDropForms | + nsIParserUtils::SanitizerLogRemovals; + nsContentUtils::ParseFragmentHTML( + NS_ConvertUTF8toUTF16(aTranslation.mValue), fragment, + nsGkAtoms::_template, kNameSpaceID_XHTML, false, true, + sanitizationFlags); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + OverlayChildNodes(fragment, &aElement, aErrors, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + } + } + + // Even if the translation doesn't define any localizable attributes, run + // overlayAttributes to remove any localizable attributes set by previous + // translations. + OverlayAttributes(aTranslation.mAttributes, &aElement, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return; + } +} diff --git a/dom/l10n/L10nOverlays.h b/dom/l10n/L10nOverlays.h new file mode 100644 index 0000000000..95b1714761 --- /dev/null +++ b/dom/l10n/L10nOverlays.h @@ -0,0 +1,111 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_l10n_L10nOverlays_h +#define mozilla_dom_l10n_L10nOverlays_h + +#include "mozilla/dom/L10nOverlaysBinding.h" +#include "mozilla/dom/LocalizationBinding.h" + +class nsINode; + +namespace mozilla::dom { + +class DocumentFragment; +class Element; + +class L10nOverlays { + public: + /** + * Translate an element. + * + * Translate the element's text content and attributes. Some HTML markup is + * allowed in the translation. The element's children with the data-l10n-name + * attribute will be treated as arguments to the translation. If the + * translation defines the same children, their attributes and text contents + * will be used for translating the matching source child. + */ + static void TranslateElement(const GlobalObject& aGlobal, Element& aElement, + const L10nMessage& aTranslation, + Nullable<nsTArray<L10nOverlaysError>>& aErrors); + static void TranslateElement(Element& aElement, + const L10nMessage& aTranslation, + nsTArray<L10nOverlaysError>& aErrors, + ErrorResult& aRv); + + private: + /** + * Create a text node from text content of an Element. + */ + static already_AddRefed<nsINode> CreateTextNodeFromTextContent( + Element* aElement, ErrorResult& aRv); + + /** + * Transplant localizable attributes of an element to another element. + * + * Any localizable attributes already set on the target element will be + * cleared. + */ + static void OverlayAttributes( + const Nullable<Sequence<AttributeNameValue>>& aTranslation, + Element* aToElement, ErrorResult& aRv); + static void OverlayAttributes(Element* aFromElement, Element* aToElement, + ErrorResult& aRv); + + /** + * Helper to set textContent and localizable attributes on an element. + */ + static void ShallowPopulateUsing(Element* aFromElement, Element* aToElement, + ErrorResult& aRv); + + /** + * Sanitize a child element created by the translation. + * + * Try to find a corresponding child in sourceElement and use it as the base + * for the sanitization. This will preserve functional attributes defined on + * the child element in the source HTML. + */ + static already_AddRefed<nsINode> GetNodeForNamedElement( + Element* aSourceElement, Element* aTranslatedChild, + nsTArray<L10nOverlaysError>& aErrors, ErrorResult& aRv); + + /** + * Check if element is allowed in the translation. + * + * This method is used by the sanitizer when the translation markup contains + * an element which is not present in the source code. + */ + static bool IsElementAllowed(Element* aElement); + + /** + * Sanitize an allowed element. + * + * Text-level elements allowed in translations may only use safe attributes + * and will have any nested markup stripped to text content. + */ + static already_AddRefed<Element> CreateSanitizedElement(Element* aElement, + ErrorResult& aRv); + + /** + * Replace child nodes of an element with child nodes of another element. + * + * The contents of the target element will be cleared and fully replaced with + * sanitized contents of the source element. + */ + static void OverlayChildNodes(DocumentFragment* aFromFragment, + Element* aToElement, + nsTArray<L10nOverlaysError>& aErrors, + ErrorResult& aRv); + + /** + * A helper used to test if the string contains HTML markup. + */ + static bool ContainsMarkup(const nsACString& aStr); +}; + +} // namespace mozilla::dom + +#endif diff --git a/dom/l10n/components.conf b/dom/l10n/components.conf new file mode 100644 index 0000000000..cbb689e776 --- /dev/null +++ b/dom/l10n/components.conf @@ -0,0 +1,14 @@ +Classes = [ + { + 'cid': '{a2017fd2-7d8d-11e9-b492-ab187f765b54}', + 'contract_ids': ['@mozilla.org/dom/l10n/localization;1'], + 'type': 'mozilla::dom::DOMLocalization', + 'headers': ['/dom/l10n/DOMLocalization.h'], + }, + { + 'cid': '{8d85597c-3a92-11e9-9ffc-73d225b2d53f}', + 'contract_ids': ['@mozilla.org/dom/l10n/overlays;1'], + 'type': 'mozilla::dom::L10nOverlays', + 'headers': ['/dom/l10n/L10nOverlays.h'], + }, +] diff --git a/dom/l10n/moz.build b/dom/l10n/moz.build new file mode 100644 index 0000000000..0beb3d668e --- /dev/null +++ b/dom/l10n/moz.build @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Core", "Internationalization") + +EXPORTS.mozilla.dom += [ + "DocumentL10n.h", + "DOMLocalization.h", + "L10nMutations.h", + "L10nOverlays.h", +] + +UNIFIED_SOURCES += [ + "DocumentL10n.cpp", + "DOMLocalization.cpp", + "L10nMutations.cpp", + "L10nOverlays.cpp", +] + +LOCAL_INCLUDES += [ + "/dom/base", +] + +FINAL_LIBRARY = "xul" + +MOCHITEST_MANIFESTS += ["tests/mochitest/mochitest.ini"] +MOCHITEST_CHROME_MANIFESTS += ["tests/mochitest/chrome.ini"] +BROWSER_CHROME_MANIFESTS += ["tests/mochitest/browser.ini"] + +if CONFIG["ENABLE_TESTS"]: + DIRS += ["tests/gtest"] diff --git a/dom/l10n/tests/gtest/TestL10nOverlays.cpp b/dom/l10n/tests/gtest/TestL10nOverlays.cpp new file mode 100644 index 0000000000..c6c9da9939 --- /dev/null +++ b/dom/l10n/tests/gtest/TestL10nOverlays.cpp @@ -0,0 +1,77 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "gtest/gtest.h" +#include "mozilla/dom/L10nOverlays.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/L10nOverlaysBinding.h" +#include "mozilla/dom/Element.h" +#include "mozilla/NullPrincipal.h" +#include "nsNetUtil.h" + +using mozilla::NullPrincipal; +using namespace mozilla::dom; + +static already_AddRefed<Document> SetUpDocument() { + nsCOMPtr<nsIURI> uri; + NS_NewURI(getter_AddRefs(uri), "about:blank"); + nsCOMPtr<nsIPrincipal> principal = + NullPrincipal::CreateWithoutOriginAttributes(); + nsCOMPtr<Document> document; + nsresult rv = NS_NewDOMDocument(getter_AddRefs(document), + u""_ns, // aNamespaceURI + u""_ns, // aQualifiedName + nullptr, // aDoctype + uri, uri, principal, + false, // aLoadedAsData + nullptr, // aEventObject + DocumentFlavorHTML); + + if (NS_WARN_IF(NS_FAILED(rv))) { + return nullptr; + } + return document.forget(); +} + +/** + * This test verifies that the basic C++ DOM L10nOverlays API + * works correctly. + */ +TEST(DOM_L10n_Overlays, Initial) +{ + mozilla::ErrorResult rv; + + // 1. Set up an HTML document. + nsCOMPtr<Document> doc = SetUpDocument(); + + // 2. Create a simple Element with a child. + // + // <div> + // <a data-l10n-name="link" href="https://www.mozilla.org"></a> + // </div> + // + RefPtr<Element> elem = doc->CreateHTMLElement(nsGkAtoms::div); + RefPtr<Element> span = doc->CreateHTMLElement(nsGkAtoms::a); + span->SetAttribute(u"data-l10n-name"_ns, u"link"_ns, rv); + span->SetAttribute(u"href"_ns, u"https://www.mozilla.org"_ns, rv); + elem->AppendChild(*span, rv); + + // 3. Create an L10nMessage with a translation for the element. + L10nMessage translation; + translation.mValue.AssignLiteral( + "Hello <a data-l10n-name=\"link\">World</a>."); + + // 4. Translate the element. + nsTArray<L10nOverlaysError> errors; + L10nOverlays::TranslateElement(*elem, translation, errors, rv); + + nsAutoString textContent; + elem->GetInnerHTML(textContent, rv); + + // 5. Verify that the innerHTML matches the expectations. + ASSERT_STREQ(NS_ConvertUTF16toUTF8(textContent).get(), + "Hello <a data-l10n-name=\"link\" " + "href=\"https://www.mozilla.org\">World</a>."); +} diff --git a/dom/l10n/tests/gtest/moz.build b/dom/l10n/tests/gtest/moz.build new file mode 100644 index 0000000000..0e1e2173a6 --- /dev/null +++ b/dom/l10n/tests/gtest/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +UNIFIED_SOURCES += [ + "TestL10nOverlays.cpp", +] + +FINAL_LIBRARY = "xul-gtest" diff --git a/dom/l10n/tests/mochitest/browser.ini b/dom/l10n/tests/mochitest/browser.ini new file mode 100644 index 0000000000..966be56661 --- /dev/null +++ b/dom/l10n/tests/mochitest/browser.ini @@ -0,0 +1,6 @@ +[document_l10n/non-system-principal/browser_resource_uri.js] +support-files = + document_l10n/non-system-principal/test.html + document_l10n/non-system-principal/localization/test.ftl +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure diff --git a/dom/l10n/tests/mochitest/chrome.ini b/dom/l10n/tests/mochitest/chrome.ini new file mode 100644 index 0000000000..68f3d89d40 --- /dev/null +++ b/dom/l10n/tests/mochitest/chrome.ini @@ -0,0 +1,47 @@ +[l10n_overlays/test_attributes.html] +[l10n_overlays/test_functional_children.html] +[l10n_overlays/test_text_children.html] +[l10n_overlays/test_extra_text_markup.html] +[l10n_overlays/test_l10n_overlays.xhtml] +[l10n_overlays/test_same_id.html] +[l10n_overlays/test_same_id_args.html] +[l10n_overlays/test_title.html] + +[l10n_mutations/test_append_content_post_dcl.html] +[l10n_mutations/test_append_content_pre_dcl.html] +[l10n_mutations/test_append_fragment_post_dcl.html] +[l10n_mutations/test_disconnectedRoot_webcomponent.html] +[l10n_mutations/test_set_attributes.html] +[l10n_mutations/test_pause_observing.html] +[l10n_mutations/test_template.html] +[l10n_mutations/test_remove_element.html] +[l10n_mutations/test_remove_fragment.html] + +[dom_localization/test_attr_sanitized.html] +[dom_localization/test_getAttributes.html] +[dom_localization/test_setAttributes.html] +[dom_localization/test_translateElements.html] +[dom_localization/test_translateFragment.html] +[dom_localization/test_connectRoot.html] +[dom_localization/test_connectRoot_webcomponent.html] +[dom_localization/test_disconnectRoot.html] +[dom_localization/test_repeated_l10nid.html] +[dom_localization/test_translateRoots.html] +[dom_localization/test_l10n_mutations.html] +[dom_localization/test_overlay.html] +[dom_localization/test_overlay_repeated.html] +[dom_localization/test_overlay_missing_all.html] +[dom_localization/test_overlay_missing_children.html] +[dom_localization/test_overlay_sanitized.html] +[dom_localization/test_domloc.xhtml] + + +[document_l10n/test_docl10n.xhtml] +[document_l10n/test_docl10n.html] +[document_l10n/test_docl10n_sync.html] +[document_l10n/test_docl10n_ready_rejected.html] +[document_l10n/test_docl10n_removeResourceIds.html] +[document_l10n/test_docl10n_lazy.html] +[document_l10n/test_connectRoot_webcomponent.html] +[document_l10n/test_connectRoot_webcomponent_lazy.html] +[document_l10n/test_telemetry.html] diff --git a/dom/l10n/tests/mochitest/document_l10n/README.txt b/dom/l10n/tests/mochitest/document_l10n/README.txt new file mode 100644 index 0000000000..b798a5039a --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/README.txt @@ -0,0 +1,3 @@ +Tests in this directory cover support for DocumentL10n +WebIDL API across different use cases such as +processes, principals and so on. diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt new file mode 100644 index 0000000000..d0cc074166 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/README.txt @@ -0,0 +1,3 @@ +Tests in this directory cover the functionality +of DocumentL10n WebIDL API in non-system-principal +scenario. diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js new file mode 100644 index 0000000000..a658e88bec --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/browser_resource_uri.js @@ -0,0 +1,109 @@ +let uri = + "chrome://mochitests/content/browser/dom/l10n/tests/mochitest//document_l10n/non-system-principal/"; +let protocol = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + +protocol.setSubstitution("l10n-test", Services.io.newURI(uri)); + +// Since we want the mock source to work with all locales, we're going +// to register it for currently used locales, and we'll put the path that +// doesn't use the `{locale}` component to make it work irrelevant of +// what locale the mochitest is running in. +// +// Notice: we're using a `chrome://` protocol here only for convenience reasons. +// Real sources should use `resource://` protocol. +let locales = Services.locale.appLocalesAsBCP47; + +// This source is actually using a real `FileSource` instead of a mocked one, +// because we want to test that fetching real I/O out of the `uri` works in non-system-principal. +let source = new L10nFileSource("test", "app", locales, `${uri}localization/`); +L10nRegistry.getInstance().registerSources([source]); + +registerCleanupFunction(() => { + protocol.setSubstitution("l10n-test", null); + L10nRegistry.getInstance().removeSources(["test"]); + SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processPrelaunch.enabled", true]], + }); +}); + +const kChildPage = getRootDirectory(gTestPath) + "test.html"; + +const kAboutPagesRegistered = Promise.all([ + BrowserTestUtils.registerAboutPage( + registerCleanupFunction, + "test-about-l10n-child", + kChildPage, + Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.ALLOW_SCRIPT + ), +]); + +add_task(async () => { + // Bug 1640333 - windows fails (sometimes) to ever get document.l10n.ready + // if e10s process caching is enabled + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processPrelaunch.enabled", false], + ["dom.security.skip_about_page_has_csp_assert", true], + ], + }); + await kAboutPagesRegistered; + await BrowserTestUtils.withNewTab( + "about:test-about-l10n-child", + async browser => { + await SpecialPowers.spawn(browser, [], async function () { + let document = content.document; + let window = document.defaultView; + + await document.testsReadyPromise; + + let principal = SpecialPowers.wrap(document).nodePrincipal; + is( + principal.spec, + "about:test-about-l10n-child", + "correct content principal" + ); + + let desc = document.getElementById("main-desc"); + + // We can test here for a particular value because we're + // using a mock file source which is locale independent. + // + // If you're writing a test that verifies that a UI + // widget got real localization, you should not rely on + // the particular value, but rather on the content not + // being empty (to keep the test pass in non-en-US locales). + is(desc.textContent, "This is a mock page title"); + + // Test for l10n.getAttributes + let label = document.getElementById("label1"); + let l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, "subtitle"); + is(l10nArgs.args.name, "Firefox"); + + // Test for manual value formatting + let customMsg = document.getElementById("customMessage").textContent; + is(customMsg, "This is a custom message formatted from JS."); + + // Since we applied the `data-l10n-id` attribute + // on `label` in this microtask, we have to wait for + // the next paint to verify that the MutationObserver + // applied the translation. + await new Promise(resolve => { + let verifyL10n = () => { + if (!label.textContent.includes("Firefox")) { + window.requestAnimationFrame(verifyL10n); + } else { + resolve(); + } + }; + + window.requestAnimationFrame(verifyL10n); + }); + }); + } + ); +}); diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl new file mode 100644 index 0000000000..a5da5a8f00 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/localization/test.ftl @@ -0,0 +1,4 @@ +page-title = This is a mock page title +subtitle = This is a label for { $name } + +custom-message = This is a custom message formatted from JS. diff --git a/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html new file mode 100644 index 0000000000..5d91f3da46 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/non-system-principal/test.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DocumentL10n in HTML environment</title> + <link rel="localization" href="test.ftl"/> + <script type="text/javascript"> + document.testsReadyPromise = new Promise((resolve) => { + // The test is in this file to ensure that we're testing + // the behavior in a non-system principal. + document.addEventListener("DOMContentLoaded", async () => { + await document.l10n.ready; + + // Assign the localization from JS + let label = document.getElementById("label1"); + document.l10n.setAttributes( + label, + "subtitle", + { + name: "Firefox", + } + ); + + const customMsg = await document.l10n.formatValue("custom-message"); + document.getElementById("customMessage").textContent = customMsg; + resolve(); + }, {once: true}); + }); + </script> +</head> +<body> + <h1 id="main-desc" data-l10n-id="page-title"></h1> + + <p id="label1"></p> + <p id="customMessage"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html new file mode 100644 index 0000000000..3f2def3547 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent.html @@ -0,0 +1,90 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Web Component connecting into Document's l10n</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="browser/preferences/preferences.ftl"></link> + <script type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + // In this test we are introducing two widgets. The only difference between them + // is that the first one is using `connectRoot` with the `aTranslate` argument set to `true`, + // and the other one to `false`. + // + // In this test, we will inject both of them into the DOM for parsing. + // For a test that verifies the behavior when they're injected lazily, see + // `test_connectRoot_webcomponent_lazy.html` test. + // + // Since both widgets get injected into DOM during parsing, we expect both of them + // to get translated before `document.l10n.ready` is resolved. + + let passedTests = 0; + + class FluentWidget extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + async connectedCallback() { + MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl"); + + document.l10n.connectRoot(this.shadowRoot, true); + + let label = this.shadowRoot.getElementById("label"); + + await document.l10n.ready; + is(label.textContent, "Learn more", "localization content applied to element"); + passedTests++; + if (passedTests == 2) { + SimpleTest.finish(); + } + } + } + + class FluentWidget2 extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + async connectedCallback() { + MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl"); + + document.l10n.connectRoot(this.shadowRoot, false); + + let label = this.shadowRoot.getElementById("label"); + + await document.l10n.ready; + is(label.textContent, "Learn more", "localization content applied to element"); + passedTests++; + if (passedTests == 2) { + SimpleTest.finish(); + } + } + } + + customElements.define("fluent-widget", FluentWidget); + customElements.define("fluent-widget2", FluentWidget2); + </script> +</head> +<body> + <template id="fluent-widget-template"> + <div> + <button id="label" data-l10n-id="do-not-track-learn-more"></button> + </div> + </template> + <fluent-widget></fluent-widget> + <fluent-widget2></fluent-widget2> + <script> + // This trick makes sure that we connect the widgets before parsing is completed. + document.write(""); + </script> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html new file mode 100644 index 0000000000..b74bbc00c8 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_connectRoot_webcomponent_lazy.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Web Component connecting into Document's l10n</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + // In this test we are introducing two widgets. The only difference between them + // is that the first one is using `connectRoot` with the `aTranslate` argument set to `true`, + // and the other one to `false`. + // + // In this test, we will inject both of them lazily, after initial parsing is completed. + // For a test that verifies the behavior when they're injected during parsing, see + // `test_connectRoot_webcomponent.html` test. + // + // The expected difference is that when both get lazily injected into the DOM, the first one + // will get translated, while the other will not. + // The latter behavior will be used by widgets that will want to translate the initial DOM on their + // own before connecting the root. + + let firstWidgetTranslated = false; + + class FluentWidget extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + connectedCallback() { + MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl"); + + document.l10n.connectRoot(this.shadowRoot, true); + + let label = this.shadowRoot.getElementById("label"); + + let verifyL10n = () => { + if (label.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + is(label.textContent, "Learn more", "localization content applied to element"); + firstWidgetTranslated = true; + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + } + } + + class FluentWidget2 extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + connectedCallback() { + MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl"); + + document.l10n.connectRoot(this.shadowRoot, false); + + let label = this.shadowRoot.getElementById("label"); + + let verifyL10n = () => { + if (firstWidgetTranslated) { + is(label.textContent.length, 0, "This widget should remain untranslated."); + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + } + } + + customElements.define("fluent-widget", FluentWidget); + customElements.define("fluent-widget2", FluentWidget2); + + window.addEventListener("load", () => { + window.requestIdleCallback(async () => { + let widget = document.createElement("fluent-widget"); + document.body.appendChild(widget); + let widget2 = document.createElement("fluent-widget2"); + document.body.appendChild(widget2); + }); + }, { once: true }); + </script> +</head> +<body> + <template id="fluent-widget-template"> + <div> + <button id="label" data-l10n-id="do-not-track-learn-more"></button> + </div> + </template> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html new file mode 100644 index 0000000000..12ff623f5c --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DocumentL10n in HTML environment</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + is(document.l10n.ready && document.l10n.ready.then !== undefined, true, + "document.l10n.ready exists and is a Promise"); + + (async function() { + await document.l10n.ready; + + const desc = document.getElementById("main-desc"); + is(!!desc.textContent.length, true, "initial localization is applied"); + + const msg = await document.l10n.formatValue("id-heading"); + is(!!msg.length, true, "value is formatted manually"); + + const label = document.getElementById("label1"); + let l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, null, "id is null if not set"); + + SimpleTest.doesThrow( + () => { + const bad = {}; + bad.bad = bad; + document.l10n.setAttributes(label, "date-crashed-heading", bad); + }, + "an error is thrown for invalid args", + ); + + l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, null, "id is not set if args are invalid"); + + document.l10n.setAttributes( + label, + "date-crashed-heading", + { + name: "John", + } + ); + ok(document.hasPendingL10nMutations, "Should have pending mutations"); + l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, "date-crashed-heading", "id is set by setAttributes"); + is(l10nArgs.args.name, "John", "args are set by setAttributes"); + // Test for mutations applied. + document.addEventListener("L10nMutationsFinished", function() { + ok(!!label.textContent.length, "Should've applied translation"); + ok(!document.hasPendingL10nMutations, "Should have no more pending mutations"); + SimpleTest.finish(); + }, { once: true }); + })(); + </script> +</head> +<body> + <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1> + + <p id="label1"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml new file mode 100644 index 0000000000..2d51d8689e --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n.xhtml @@ -0,0 +1,60 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<!DOCTYPE html> + +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <meta charset="utf-8"></meta> + <title>Test DocumentL10n in HTML environment</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"></link> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + await document.l10n.ready; + + // Test for initial localization applied. + let desc = document.getElementById("main-desc"); + is(!!desc.textContent.length, true); + + // Test for manual value formatting + let msg = await document.l10n.formatValue("id-heading"); + is(!!msg.length, true); + + // Test for mutations applied. + let verifyL10n = () => { + if (label.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + let label = document.getElementById("label1"); + document.l10n.setAttributes( + label, + "date-crashed-heading", + { + name: "John", + } + ); + + // Test for l10n.getAttributes + let l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, "date-crashed-heading"); + is(l10nArgs.args.name, "John"); + }, { once: true}); + </script> +</head> +<body> + <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1> + + <p id="label1" /> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html new file mode 100644 index 0000000000..6c3ddb73ed --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_lazy.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Lazy DocumentL10n in HTML environment</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + is(document.l10n, null, "document.l10n is null."); + + window.addEventListener("load", async () => { + is(document.l10n, null, "document.l10n is null after load."); + + let desc = document.getElementById("main-desc"); + is(desc.textContent.length, 0, "main-desc is not translated"); + + let link = document.createElement("link"); + link.setAttribute("rel", "localization"); + link.setAttribute("href", "crashreporter/aboutcrashes.ftl"); + document.head.appendChild(link); + + // Verify now that `l10n.ready` exists and is fulfilled. + await document.l10n.ready; + + // Lazy initialized localization should translate the document. + is(!!desc.textContent.length, true, "main-desc is translated"); + + document.head.removeChild(link); + + is(document.l10n, null, "document.l10n is null"); + + SimpleTest.finish(); + }, { once: true}); + </script> +</head> +<body> + <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1> + + <p id="label1"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html new file mode 100644 index 0000000000..63e18f802c --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_ready_rejected.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test mozIDOMLocalization.ready rejected state</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="path/to_non_existing.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + /** + * Even when we fail to localize all elements, we will + * still resolve the `ready` promise to communicate that + * the initial translation phase is now completed. + */ + document.l10n.ready.then(() => { + is(1, 1, "the ready should resolve"); + SimpleTest.finish(); + }); + }); + </script> +</head> +<body> + <h1 data-l10n-id="non-existing-id"></h1> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html new file mode 100644 index 0000000000..8ccaa04614 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_removeResourceIds.html @@ -0,0 +1,59 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DocumentL10n::RemoveResourceIds</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="toolkit/about/aboutAddons.ftl"/> + <link rel="localization" href="toolkit/about/aboutSupport.ftl"/> + <script type="application/javascript"> + "use strict"; + /* eslint-disable mozilla/prefer-formatValues */ + + SimpleTest.waitForExplicitFinish(); + + window.onload = async function() { + { + // 1. An example message from aboutAddons should be available. + let value = await document.l10n.formatValue("shortcuts-browserAction2"); + is(!!value.length, true, "localized value retrieved"); + } + + { + // 2. Remove aboutAddons.ftl + let link = document.head.querySelector("link[href*=aboutAddons]"); + document.head.removeChild(link); + } + + { + // 3. An example message from aboutSupport should still be available. + let value = await document.l10n.formatValue("features-version"); + is(!!value.length, true, "localized value retrieved"); + + // 4. An example message from aboutAddons should not be available. + await document.l10n.formatValue("shortcuts-browserAction").then( + () => { + ok(false, "localization should not be available"); + }, + () => { + ok(true, "localization should not be available"); + }); + } + + { + // 5. Remove aboutSupport.ftl + let link = document.head.querySelector("link[href*=aboutSupport]"); + document.head.removeChild(link); + + // 6. document.l10n should be null. + is(document.l10n, null, "document.l10n should be null"); + + SimpleTest.finish(); + } + }; + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html new file mode 100644 index 0000000000..ea44d1afe1 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_docl10n_sync.html @@ -0,0 +1,54 @@ +<!DOCTYPE HTML> +<html data-l10n-sync> +<head> + <meta charset="utf-8"> + <title>Test DocumentL10n in HTML environment</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + await document.l10n.ready; + + // Test for initial localization applied. + let desc = document.getElementById("main-desc"); + is(!!desc.textContent.length, true); + + // Test for manual value formatting. + let msg = await document.l10n.formatValue("id-heading"); + is(!!msg.length, true); + + // Test for mutations applied. + let verifyL10n = () => { + if (label.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + let label = document.getElementById("label1"); + document.l10n.setAttributes( + label, + "date-crashed-heading", + { + name: "John", + } + ); + + // Test for l10n.getAttributes + let l10nArgs = document.l10n.getAttributes(label); + is(l10nArgs.id, "date-crashed-heading"); + is(l10nArgs.args.name, "John"); + }, { once: true}); + </script> +</head> +<body> + <h1 id="main-desc" data-l10n-id="crash-reports-title"></h1> + + <p id="label1"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html b/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html new file mode 100644 index 0000000000..b528ebc7ea --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_telemetry.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DocumentL10n Telemetry</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" + ); + + SimpleTest.waitForExplicitFinish(); + + function countValues(snapshot, key) { + if (!snapshot.hasOwnProperty(key)) { + return 0; + } + let values = snapshot[key].values; + return Object.values(values).reduce((sum, n) => sum + n, 0); + } + + (async function() { + let histogram = Services.telemetry + .getKeyedHistogramById("L10N_DOCUMENT_INITIAL_TRANSLATION_TIME_US"); + let snapshot = histogram.snapshot(); + + // In some cases the test runs before first window is localized. + // We just want to ensure that we didn't register more than 1 + // first window telemetry. + let firstWindowCount = countValues(snapshot, "browser_first_window"); + is(firstWindowCount < 2, true); + + histogram.clear(); + + // Open a new window + let win = await BrowserTestUtils.openNewBrowserWindow({ + waitForTabURL: "about:blank", + }); + + // Telemetry in testing is flaky and when landing this test + // we saw cases where the snapshot did not contain the new + // window telemetry at this moment. + // + // We're going to use `waitForCondition` to test for + // the telemetry to be eventually recorded. + BrowserTestUtils.waitForCondition(() => { + snapshot = histogram.snapshot(); + + // We want to make sure that since we cleared histogram + // just one new window of either type has been opened. + let browserWindowsCount = + countValues(snapshot, "browser_new_window") + + countValues(snapshot, "browser_first_window"); + return browserWindowsCount == 1; + }); + + // Open preferences in a new tab + let tab = BrowserTestUtils.addTab( + win.gBrowser, + "about:preferences" + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Similarly to the above, we've seen cases where the telemetry + // was not present right after the tab was opened, so + // we'll use `waitForCondition` here. + BrowserTestUtils.waitForCondition(() => { + snapshot = histogram.snapshot(); + + return countValues(snapshot, "about:preferences") == 1; + }); + + await BrowserTestUtils.closeWindow(win); + + SimpleTest.finish(); + })(); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html b/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html new file mode 100644 index 0000000000..4f4b29c500 --- /dev/null +++ b/dom/l10n/tests/mochitest/document_l10n/test_unpriv_iframe.html @@ -0,0 +1,26 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Ensure unprivilaged document cannot access document.l10n in an iframe</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + addLoadEvent(function() { + let frame = document.getElementById("frame"); + let frame2 = document.getElementById("frame2"); + + is("l10n" in frame.contentDocument, false); + is("l10n" in frame2.contentDocument, false); + }); + addLoadEvent(SimpleTest.finish); + </script> +</head> +<body> + <iframe id="frame" src="about:blank"></iframe> + <iframe id="frame2" src="about:crashes"></iframe> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html b/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html new file mode 100644 index 0000000000..a9244c9891 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_attr_sanitized.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's attr sanitization functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Value for Key 1 + +key2 = Value for <a>Key 2<a/>. +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + SimpleTest.waitForExplicitFinish(); + addLoadEvent(async () => { + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body); + + const elem1 = document.querySelector("#elem1"); + const elem2 = document.querySelector("#elem2"); + + ok(elem1.textContent.includes("Value for")); + ok(!elem1.hasAttribute("title")); + + ok(elem2.textContent.includes("Value for")); + ok(!elem2.hasAttribute("title")); + + SimpleTest.finish(); + }); + </script> +</head> +<body> + <p id="elem1" title="Old Translation" data-l10n-id="key1"></p> + <p id="elem2" title="Old Translation" data-l10n-id="key2"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html new file mode 100644 index 0000000000..83b83757f1 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot.html @@ -0,0 +1,45 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.connectRoot</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Value for Key 1 +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const p1 = document.getElementById("p1"); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateRoots(); + is(!p1.textContent.length, true); + const body = document.body; + domLoc.connectRoot(body); + await domLoc.translateRoots(); + is(!!p1.textContent.length, true); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p id="p1" data-l10n-id="key1"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html new file mode 100644 index 0000000000..1254bd814f --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_connectRoot_webcomponent.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.connectRoot with Web Components</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + SimpleTest.waitForExplicitFinish(); + + class FluentWidget extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + connectedCallback() { + document.domLoc.connectRoot(this.shadowRoot); + ok(true); + + let label = this.shadowRoot.getElementById("label"); + + // Test for mutations applied. + let verifyL10n = () => { + if (label.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + // Notice: In normal tests we do not want to test against any particular + // value as per https://firefox-source-docs.mozilla.org/l10n/fluent/tutorial.html#testing + // But in this particular test, since we do not rely on actual real + // localization, but instead we mock it in the test, we can test + // against the actual value safely. + is(label.textContent, "Value for Key 1", "localization content applied to element"); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + document.domLoc.setAttributes(label, "key1"); + } + } + customElements.define("fluent-widget", FluentWidget); + </script> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Value for Key 1 +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + document.domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + </script> +</head> +<body> + <template id="fluent-widget-template"> + <div> + <p id="label"></p> + </div> + </template> + <fluent-widget id="widget1"></fluent-widget> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html b/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html new file mode 100644 index 0000000000..fd7344c88e --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_disconnectRoot.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.disconnectRoot</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Value for Key 1 +key2 = Value for Key 2 +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const p1 = document.getElementById("p1"); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateRoots(); + is(!p1.textContent.length, true); + + const body = document.body; + + domLoc.connectRoot(body); + await domLoc.translateRoots(); + is(p1.textContent.includes("Key 1"), true); + is(p1.textContent.includes("Key 2"), false); + + domLoc.disconnectRoot(body); + domLoc.setAttributes(p1, "key2"); + await domLoc.translateRoots(); + is(p1.textContent.includes("Key 1"), true); + is(p1.textContent.includes("Key 2"), false); + + domLoc.connectRoot(body); + await domLoc.translateRoots(); + is(p1.textContent.includes("Key 1"), false); + is(p1.textContent.includes("Key 2"), true); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p id="p1" data-l10n-id="key1"></p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml b/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml new file mode 100644 index 0000000000..a4d4fcc506 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_domloc.xhtml @@ -0,0 +1,68 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Testing DOMLocalization in XUL environment"> + + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript"> + <![CDATA[ + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +file-menu = + .label = File + .accesskey = F +new-tab = + .label = New Tab + .accesskey = N +container = Some text with an <image data-l10n-name="foo"> inside it. +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + SimpleTest.waitForExplicitFinish(); + + + const domLoc = new DOMLocalization( + [], + false, + l10nReg, + ["en-US"], + ); + + async function foo() { + domLoc.addResourceIds(["/mock.ftl"]); + domLoc.connectRoot(document.documentElement); + await domLoc.translateRoots(); + + is(document.getElementById('file-menu').getAttribute('label'), 'File'); + is(document.getElementById('file-menu').getAttribute('accesskey'), 'F'); + + is(document.getElementById('new-tab').getAttribute('label'), 'New Tab'); + is(document.getElementById('new-tab').getAttribute('accesskey'), 'N'); + + ok(document.querySelector("image"), + "Image should still be present after localization."); + SimpleTest.finish(); + } + + window.onload = foo; + + ]]> + </script> + <description data-l10n-id="container"><image data-l10n-name="foo"/></description> + + <menubar id="main-menubar"> + <menu id="file-menu" data-l10n-id="file-menu"> + <menupopup id="menu_FilePopup"> + <menuitem id="new-tab" data-l10n-id="new-tab"> + </menuitem> + </menupopup> + </menu> + </menubar> +</window> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html b/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html new file mode 100644 index 0000000000..11996d132a --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_getAttributes.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.getAttributes</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + + window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + [], + false, + ); + + const p1 = document.querySelectorAll("p")[0]; + const p2 = document.querySelectorAll("p")[1]; + const p3 = document.querySelectorAll("p")[2]; + const attrs1 = domLoc.getAttributes(p1); + const attrs2 = domLoc.getAttributes(p2); + const attrs3 = domLoc.getAttributes(p3); + isDeeply(attrs1, { + id: null, + args: null, + }); + isDeeply(attrs2, { + id: "id1", + args: null, + }); + isDeeply(attrs3, { + id: "id2", + args: { + userName: "John", + }, + }); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p /> + <p data-l10n-id="id1" /> + <p data-l10n-id="id2" data-l10n-args='{"userName": "John"}' /> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html b/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html new file mode 100644 index 0000000000..278baa15dd --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_l10n_mutations.html @@ -0,0 +1,57 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's MutationObserver</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Hello World +title2 = Hello Another World +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + [], + false, + l10nReg, + ["en-US"], + ); + + const h1 = document.querySelectorAll("h1")[0]; + + domLoc.addResourceIds(["/mock.ftl"]); + domLoc.connectRoot(document.body); + + await domLoc.translateRoots(); + + is(h1.textContent, "Hello World"); + + + const mo = new MutationObserver(function onMutations(mutations) { + is(h1.textContent, "Hello Another World"); + mo.disconnect(); + SimpleTest.finish(); + }); + + mo.observe(h1, { childList: true, characterData: true }); + + domLoc.setAttributes(h1, "title2"); + }; + </script> +</head> +<body> + <div> + <h1 data-l10n-id="title"></h1> + </div> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay.html new file mode 100644 index 0000000000..2c8c219bb2 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's DOMOverlay functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = <strong>Hello</strong> World +title2 = This is <a data-l10n-name="link">a link</a>! +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + const p1 = document.querySelectorAll("p")[0]; + const p2 = document.querySelectorAll("p")[1]; + const a = p2.querySelector("a"); + // We want to test that the event listener persists after + // translateFragment(). + a.addEventListener("click", function(e) { + SimpleTest.finish(); + // We cannot connect to non-local connections on automation, so prevent + // the navigation. + e.preventDefault(); + }); + + await domLoc.translateFragment(document.body); + + + is(p1.querySelector("strong").textContent, "Hello"); + + is(p2.querySelector("a").getAttribute("href"), "http://www.mozilla.org"); + is(p2.querySelector("a").textContent, "a link"); + + a.click(); + }; + </script> +</head> +<body> + <p data-l10n-id="title" /> + <p data-l10n-id="title2"> + <a href="http://www.mozilla.org" data-l10n-name="link"></a> + </p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html new file mode 100644 index 0000000000..c6f285aa38 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_all.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's DOMOverlay functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + + SimpleTest.waitForExplicitFinish(); + addLoadEvent(async () => { + const domLoc = new DOMLocalization( + [], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body).then(() => { + ok(false, "Expected translateFragment to throw on missing l10n-id"); + }, () => { + ok(true, "Expected translateFragment to throw on missing l10n-id"); + }); + SimpleTest.finish(); + }); + </script> +</head> +<body> + <p data-l10n-id="title"> + <a href="http://www.mozilla.org"></a> + <a href="http://www.firefox.com"></a> + <a href="http://www.w3.org"></a> + </p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html new file mode 100644 index 0000000000..9e4fec1ce2 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_missing_children.html @@ -0,0 +1,53 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's DOMOverlay functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website! +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body); + + const p1 = document.querySelectorAll("p")[0]; + const linkList = p1.querySelectorAll("a"); + + + is(linkList[0].getAttribute("href"), "http://www.mozilla.org"); + is(linkList[0].textContent, "Mozilla"); + is(linkList[1].getAttribute("href"), "http://www.firefox.com"); + is(linkList[1].textContent, "Firefox"); + + is(linkList.length, 2, "There should be exactly two links in the result."); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p data-l10n-id="title"> + <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a> + <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a> + <a href="http://www.w3.org" data-l10n-name="w3-link"></a> + </p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html new file mode 100644 index 0000000000..a169c27fa0 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_repeated.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's DOMOverlay functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Visit <a data-l10n-name="mozilla-link">Mozilla</a> or <a data-l10n-name="firefox-link">Firefox</a> website! +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body); + + const p1 = document.querySelectorAll("p")[0]; + const linkList = p1.querySelectorAll("a"); + + + is(linkList[0].getAttribute("href"), "http://www.mozilla.org"); + is(linkList[0].textContent, "Mozilla"); + is(linkList[1].getAttribute("href"), "http://www.firefox.com"); + is(linkList[1].textContent, "Firefox"); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p data-l10n-id="title"> + <a href="http://www.mozilla.org" data-l10n-name="mozilla-link"></a> + <a href="http://www.firefox.com" data-l10n-name="firefox-link"></a> + </p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html b/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html new file mode 100644 index 0000000000..fcc201edb6 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_overlay_sanitized.html @@ -0,0 +1,52 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's DOMOverlay functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = + .href = https://www.hacked.com + +key2 = + .href = https://pl.wikipedia.org +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + async function test() { + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body); + + const key1Elem = document.querySelector("[data-l10n-id=key1]"); + const key2Elem = document.querySelector("[data-l10n-id=key2]"); + + + is(key1Elem.hasAttribute("href"), false, "href translation should not be allowed"); + is(key2Elem.getAttribute("href"), "https://pl.wikipedia.org", + "href translation should be allowed"); + + SimpleTest.finish(); + } + + SimpleTest.waitForExplicitFinish(); + addLoadEvent(test); + </script> +</head> +<body> + <a data-l10n-id="key1"></a> + <a data-l10n-id="key2" data-l10n-attrs="href"></a> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html b/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html new file mode 100644 index 0000000000..64d585f0a0 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_repeated_l10nid.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization's matching l10nIds functionality</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Translation For Key 1 + +key2 = Visit <a data-l10n-name="link">this link<a/>. +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + SimpleTest.waitForExplicitFinish(); + addLoadEvent(async () => { + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + await domLoc.translateFragment(document.body); + + ok(document.querySelector("#elem1").textContent.includes("Key 1")); + ok(document.querySelector("#elem2").textContent.includes("Key 1")); + + const elem3 = document.querySelector("#elem3"); + const elem4 = document.querySelector("#elem4"); + + ok(elem3.textContent.includes("Visit")); + is(elem3.querySelector("a").getAttribute("href"), "http://www.mozilla.org"); + + ok(elem4.textContent.includes("Visit")); + is(elem4.querySelector("a").getAttribute("href"), "http://www.firefox.com"); + + SimpleTest.finish(); + }); + </script> +</head> +<body> + <h1 id="elem1" data-l10n-id="key1"></h1> + <h2 id="elem2" data-l10n-id="key1"></h2> + + <p id="elem3" data-l10n-id="key2"> + <a href="http://www.mozilla.org" data-l10n-name="link"></a> + </p> + + <p id="elem4" data-l10n-id="key2"> + <a href="http://www.firefox.com" data-l10n-name="link"></a> + </p> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html b/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html new file mode 100644 index 0000000000..508a15dcd3 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_setAttributes.html @@ -0,0 +1,79 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.setAttributes and DOMLocalization.prototype.setArgs</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + + window.onload = function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + [], + false, + l10nReg, + ); + + const p1 = document.querySelectorAll("p")[0]; + + domLoc.setAttributes(p1, "title"); + is( + p1.getAttribute("data-l10n-id"), + "title", + "The data-l10n-id can be set by setAttributes." + ); + is( + p1.getAttribute("data-l10n-args"), + null, + "The data-l10n-args is unset." + ); + + + domLoc.setAttributes(p1, "title2", {userName: "John"}); + is( + p1.getAttribute("data-l10n-id"), + "title2", + "The data-l10n-id can be set by setAttributes." + ); + is( + p1.getAttribute("data-l10n-args"), + JSON.stringify({userName: "John"}), + "The data-l10n-args can be set by setAttributes." + ); + + domLoc.setArgs(p1, {userName: "Jane"}); + is( + p1.getAttribute("data-l10n-id"), + "title2", + "The data-l10n-id is unchanged by setArgs." + ); + is( + p1.getAttribute("data-l10n-args"), + JSON.stringify({userName: "Jane"}), + "The data-l10n-args can by set by setArgs." + ); + + domLoc.setArgs(p1); + is( + p1.getAttribute("data-l10n-id"), + "title2", + "The data-l10n-id is unchanged by setArgs." + ); + is( + p1.getAttribute("data-l10n-args"), + null, + "The data-l10n-args be unset by setArgs." + ); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p /> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html b/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html new file mode 100644 index 0000000000..7893309ce4 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_translateElements.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.translateElements</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Hello World +link = + .title = Click me +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + const p1 = document.querySelectorAll("p")[0]; + const link1 = document.querySelectorAll("a")[0]; + + await domLoc.translateElements([p1, link1]); + + is(p1.textContent, "Hello World"); + is(link1.getAttribute("title"), "Click me"); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <p data-l10n-id="title" /> + <a data-l10n-id="link" /> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html b/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html new file mode 100644 index 0000000000..c5ccfd996a --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_translateFragment.html @@ -0,0 +1,48 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.translateFragment</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Hello World +subtitle = Welcome to FluentBundle +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + + const frag = document.querySelectorAll("div")[0]; + const h1 = document.querySelectorAll("h1")[0]; + const p1 = document.querySelectorAll("p")[0]; + + await domLoc.translateFragment(frag); + is(h1.textContent, "Hello World"); + is(p1.textContent, "Welcome to FluentBundle"); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <div> + <h1 data-l10n-id="title" /> + <p data-l10n-id="subtitle" /> + </div> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html b/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html new file mode 100644 index 0000000000..58bfb044b0 --- /dev/null +++ b/dom/l10n/tests/mochitest/dom_localization/test_translateRoots.html @@ -0,0 +1,56 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.translateRoots</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +title = Hello World +title2 = Hello Another World +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + window.onload = async function() { + SimpleTest.waitForExplicitFinish(); + + const domLoc = new DOMLocalization( + [], + false, + l10nReg, + ["en-US"], + ); + + const frag1 = document.querySelectorAll("div")[0]; + const frag2 = document.querySelectorAll("div")[1]; + const h1 = document.querySelectorAll("h1")[0]; + const h2 = document.querySelectorAll("h2")[0]; + + domLoc.addResourceIds(["/mock.ftl"]); + domLoc.connectRoot(frag1); + domLoc.connectRoot(frag2); + + await domLoc.translateRoots(); + + is(h1.textContent, "Hello World"); + is(h2.textContent, "Hello Another World"); + + SimpleTest.finish(); + }; + </script> +</head> +<body> + <div> + <h1 data-l10n-id="title"></h1> + </div> + <div> + <h2 data-l10n-id="title2"></h2> + </div> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html new file mode 100644 index 0000000000..e3a9819728 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_post_dcl.html @@ -0,0 +1,30 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for ContentAppended after DOMContentLoaded</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", function() { + let elem = document.createElement("div"); + document.l10n.setAttributes(elem, "crash-reports-title"); + is(elem.textContent.length, 0); + let verifyL10n = () => { + if (elem.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + document.body.appendChild(elem); + }); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html new file mode 100644 index 0000000000..825464e7b8 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_content_pre_dcl.html @@ -0,0 +1,28 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for ContentAppended before DOMContentLoaded</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> +</head> +<body> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + let elem = document.createElement("div"); + document.l10n.setAttributes(elem, "crash-reports-title"); + is(elem.textContent.length, 0); + let verifyL10n = () => { + if (elem.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + document.body.appendChild(elem); + </script> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html b/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html new file mode 100644 index 0000000000..8efb3203c6 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_append_fragment_post_dcl.html @@ -0,0 +1,39 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for appending a fragment after DOMContentLoaded</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", function() { + let frag = document.createDocumentFragment(); + let elem = document.createElement("div"); + document.l10n.setAttributes(elem, "crash-reports-title"); + frag.appendChild(elem); + + let elem2 = document.createElement("div"); + document.l10n.setAttributes(elem2, "crash-reports-title"); + frag.appendChild(elem2); + + is(elem.textContent.length, 0); + is(elem2.textContent.length, 0); + + let verifyL10n = () => { + if (elem.textContent.length && elem2.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + document.body.appendChild(frag); + }); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html b/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html new file mode 100644 index 0000000000..bb9d9fc24d --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_disconnectedRoot_webcomponent.html @@ -0,0 +1,148 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test DOMLocalization.prototype.connectRoot with Web Components</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + "use strict"; + const l10nReg = new L10nRegistry(); + const fs = [ + { path: "/localization/en-US/mock.ftl", source: ` +key1 = Key 1 +key2 = Key 2 +key3 = Key 3 +key4 = Key 4 +` }, + ]; + const source = L10nFileSource.createMock("test", "app", ["en-US"], "/localization/{locale}", fs); + l10nReg.registerSources([source]); + + document.domLoc = new DOMLocalization( + ["/mock.ftl"], + false, + l10nReg, + ["en-US"], + ); + document.domLoc.connectRoot(document.documentElement); + </script> + <script type="application/javascript"> + // In this test we're going to use two elements - `shadowLabel` and `lightLabel`. + // We create a new `DOMLocalization` and connect it to the document's root first. + // + // Then, we connect and disconnect it on root and element within the shadow DOM and + // apply new `data-l10n-id` onto both labels. + // Once the `lightLabel` get a new translation, we check what happened to the `shadowLabel` + // to ensure that depending on the status of connection between the shadow DOM and the `DOMLocalization` + // the `shadowLabel` either gets translated or not. + + SimpleTest.waitForExplicitFinish(); + + class FluentWidget extends HTMLElement { + constructor() { + super(); + const shadowRoot = this.attachShadow({mode: "open"}); + const t = document.querySelector("#fluent-widget-template"); + const instance = t.content.cloneNode(true); + shadowRoot.appendChild(instance); + } + connectedCallback() { + this.runTests(); + } + runTests() { + // First, let's verify that the mutation will not be applied since + // the shadow DOM is not connected to the `DOMLocalization`. + let shadowLabel = this.shadowRoot.getElementById("shadowLabel"); + let lightLabel = document.getElementById("lightLabel"); + + let verifyL10n = () => { + if (lightLabel.textContent == "Key 1") { + is(shadowLabel.textContent, "", "document.l10n content not applied to an element in the shadow DOM"); + window.removeEventListener("MozAfterPaint", verifyL10n); + this.testPart2(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + document.domLoc.setAttributes(shadowLabel, "key1"); + document.domLoc.setAttributes(lightLabel, "key1"); + } + testPart2() { + // Next, we connect the shadow root to DOMLocalization and the next attribute + // change should result in a translation being applied. + document.domLoc.connectRoot(this.shadowRoot); + + let shadowLabel = this.shadowRoot.getElementById("shadowLabel"); + let lightLabel = document.getElementById("lightLabel"); + + // Test that mutation was applied. + let verifyL10n = () => { + if (lightLabel.textContent == "Key 2") { + is(shadowLabel.textContent, "Key 2", "document.l10n content applied to an element in the shadow DOM"); + window.removeEventListener("MozAfterPaint", verifyL10n); + this.testPart3(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + document.domLoc.setAttributes(shadowLabel, "key2"); + document.domLoc.setAttributes(lightLabel, "key2"); + } + testPart3() { + // After we disconnect the shadow root, the mutations should + // not be applied onto the `shadowLabel`. + document.domLoc.disconnectRoot(this.shadowRoot); + + let shadowLabel = this.shadowRoot.getElementById("shadowLabel"); + let lightLabel = document.getElementById("lightLabel"); + + let verifyL10n = () => { + if (lightLabel.textContent == "Key 3") { + is(shadowLabel.textContent, "Key 2", "document.l10n content not applied to an element in the shadow DOM"); + window.removeEventListener("MozAfterPaint", verifyL10n); + this.testPart4(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + document.domLoc.setAttributes(shadowLabel, "key3"); + document.domLoc.setAttributes(lightLabel, "key3"); + } + testPart4() { + // Finally, we'll connect it back, but this time, we'll connect + // not the shadow root, but an element within it. + // This should still result in the `shadowLabel` receiving a new translation. + document.domLoc.connectRoot(this.shadowRoot.getElementById("shadowDiv")); + + let shadowLabel = this.shadowRoot.getElementById("shadowLabel"); + let lightLabel = document.getElementById("lightLabel"); + + // Test that mutation was applied. + let verifyL10n = () => { + if (lightLabel.textContent == "Key 4") { + is(shadowLabel.textContent, "Key 4", "document.l10n content applied to an element in the shadow DOM"); + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + + document.domLoc.setAttributes(shadowLabel, "key4"); + document.domLoc.setAttributes(lightLabel, "key4"); + } + } + customElements.define("fluent-widget", FluentWidget); + </script> +</head> +<body> + <p id="lightLabel"></p> + + <template id="fluent-widget-template"> + <div id="shadowDiv"> + <p id="shadowLabel"></p> + </div> + </template> + <fluent-widget id="widget1"></fluent-widget> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html b/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html new file mode 100644 index 0000000000..d225153418 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_pause_observing.html @@ -0,0 +1,44 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for Pause/Resume Observing</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", function() { + let elem1 = document.createElement("div"); + let elem2 = document.createElement("div"); + let elem3 = document.createElement("div"); + is(elem1.textContent.length, 0); + is(elem2.textContent.length, 0); + is(elem3.textContent.length, 0); + + document.l10n.setAttributes(elem1, "crash-reports-title"); + document.l10n.setAttributes(elem2, "crash-reports-title"); + document.l10n.setAttributes(elem3, "crash-reports-title"); + + let verifyL10n = () => { + if (elem1.textContent.length && + !elem2.textContent.length && + elem3.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + document.body.appendChild(elem1); + document.l10n.pauseObserving(); + document.body.appendChild(elem2); + document.l10n.resumeObserving(); + document.body.appendChild(elem3); + }); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html new file mode 100644 index 0000000000..347e858d52 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_element.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for removing element</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + // This element will be added to DOM and expected to be localized. + let elem = document.createElement("div"); + + // This element will be added to DOM, then immediatelly removed and + // expected *not* to be localized. + let elem2 = document.createElement("div"); + + // This element will be added to DOM, then immediatelly removed and + // and then immediatelly re-added, and expected to be localized. + let elem3 = document.createElement("div"); + + // This element will be added to DOM, then immediatelly removed and + // and then re-added later, and expected to be localized. + let elem4 = document.createElement("div"); + + document.l10n.setAttributes(elem, "crash-reports-title"); + document.l10n.setAttributes(elem2, "crash-reports-title"); + document.l10n.setAttributes(elem3, "crash-reports-title"); + document.l10n.setAttributes(elem4, "crash-reports-title"); + + document.body.appendChild(elem); + document.body.appendChild(elem2); + document.body.appendChild(elem3); + document.body.appendChild(elem4); + + is(elem.textContent.length, 0); + is(elem2.textContent.length, 0); + is(elem3.textContent.length, 0); + is(elem4.textContent.length, 0); + + document.body.removeChild(elem2); + document.body.removeChild(elem3); + document.body.removeChild(elem4); + + document.body.appendChild(elem3); + + // 1. `elem` should be localized since it is in DOM. + await SimpleTest.waitForCondition(() => elem.textContent.length); + // 2. `elem2` was removed before l10n frame, so it should remain not localized. + is(elem2.textContent.length, 0); + // 3. `elem3` was added/removed/re-added so it should become localized. + await SimpleTest.waitForCondition(() => elem3.textContent.length); + // 4. `elem4` was not re-added, so it shouldn't be localized. + is(elem4.textContent.length, 0); + + document.body.appendChild(elem4); + // 5. Now we re-added `elem4` to DOM so it should get localized. + await SimpleTest.waitForCondition(() => elem4.textContent.length); + SimpleTest.finish(); + }); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html new file mode 100644 index 0000000000..4f4d0fe1d8 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_remove_fragment.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for removing fragment</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + let div = document.createElement("div"); + let div2 = document.createElement("div"); + + let elem = document.createElement("p"); + let elem2 = document.createElement("p"); + let elem3 = document.createElement("p"); + let elem4 = document.createElement("p"); + + document.l10n.setAttributes(elem, "crash-reports-title"); + document.l10n.setAttributes(elem2, "crash-reports-title"); + document.l10n.setAttributes(elem3, "crash-reports-title"); + document.l10n.setAttributes(elem4, "crash-reports-title"); + + div.appendChild(elem); + div.appendChild(elem2); + div.appendChild(elem3); + div.appendChild(elem4); + + document.body.appendChild(div); + + is(elem.textContent.length, 0); + is(elem2.textContent.length, 0); + is(elem3.textContent.length, 0); + is(elem4.textContent.length, 0); + + document.body.removeChild(div); + + div2.appendChild(elem); + div2.appendChild(elem3); + + document.body.appendChild(div2); + + // 1. `elem` should be localized since it is in DOM. + await SimpleTest.waitForCondition(() => !!elem.textContent.length); + + // 2. `elem2` was removed before l10n frame, so it should remain not localized. + is(elem2.textContent.length, 0); + + // 3. `elem3` was added/removed/re-added so it should become localized. + await SimpleTest.waitForCondition(() => !!elem3.textContent.length); + + // 4. `elem4` was not re-added, so it shouldn't be localized. + is(elem4.textContent.length, 0); + + document.body.appendChild(div); + // 5. Now we re-added `elem4` to DOM so it should get localized. + await SimpleTest.waitForCondition(() => !!elem4.textContent.length); + SimpleTest.finish(); + }); + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html b/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html new file mode 100644 index 0000000000..762353a06c --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_set_attributes.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations for AttributeChange after DOMContentLoaded</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <link rel="localization" href="toolkit/about/aboutCompat.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async function() { + await document.l10n.ready; + let elem = document.getElementById("elem1"); + let elem2 = document.getElementById("elem2"); + is(elem.textContent.length, 0); + is(elem2.textContent.includes("Initial string"), true); + document.l10n.setAttributes(elem, "crash-reports-title"); + elem2.setAttribute("data-l10n-args", JSON.stringify({bug: "New string"})); + + let verifyL10n = () => { + if (elem.textContent.length && elem2.textContent.includes("New string")) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + }); + </script> +</head> +<body> + <div id="elem1"></div> + <div id="elem2" data-l10n-id="label-more-information" data-l10n-args='{"bug":"Initial string"}'></div> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_mutations/test_template.html b/dom/l10n/tests/mochitest/l10n_mutations/test_template.html new file mode 100644 index 0000000000..d02ecfcd4d --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_mutations/test_template.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10n Mutations in Template elements</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="crashreporter/aboutcrashes.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + document.addEventListener("DOMContentLoaded", async () => { + await document.l10n.ready; + let template = document.getElementById("template"); + let clone = document.importNode(template.content, true); + let span = clone.querySelector("span"); + is(span.textContent.length, 0, + "Element has not been translated while in template"); + document.body.appendChild(clone); + + let verifyL10n = () => { + if (span.textContent.length) { + window.removeEventListener("MozAfterPaint", verifyL10n); + SimpleTest.finish(); + } + }; + window.addEventListener("MozAfterPaint", verifyL10n); + }); + </script> +</head> +<body> + <template id="template"> + <span data-l10n-id="crash-reports-title"></span> + </template> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html b/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html new file mode 100644 index 0000000000..3d1f6048b2 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_attributes.html @@ -0,0 +1,86 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10nOverlays Top-level attributes</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + /* global L10nOverlays */ + "use strict"; + + function elem(name) { + return function(str) { + const element = document.createElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + { + // Allowed attribute + const element = elem("div")``; + const translation = { + value: null, + attributes: [ + {name: "title", value: "FOO"}, + ], + }; + translateElement(element, translation); + is(element.outerHTML, '<div title="FOO"></div>'); + } + + { + // Forbidden attribute + const element = elem("input")``; + const translation = { + value: null, + attributes: [ + {name: "disabled", value: "DISABLED"}, + ], + }; + translateElement(element, translation); + is(element.outerHTML, "<input>"); + } + + { + // Attributes do not leak on first translation + const element = elem("div")`Foo`; + element.setAttribute("title", "Title"); + + const translation = { + value: "FOO", + attributes: null, + }; + translateElement(element, translation); + is(element.outerHTML, "<div>FOO</div>"); + } + + { + // Attributes do not leak on retranslation + const element = elem("div")`Foo`; + + const translationA = { + value: "FOO A", + attributes: [ + {name: "title", value: "TITLE A"}, + ], + }; + + const translationB = { + value: "FOO B", + attributes: null, + }; + translateElement(element, translationA); + is(element.outerHTML, '<div title="TITLE A">FOO A</div>'); + translateElement(element, translationB); + is(element.outerHTML, "<div>FOO B</div>"); + } + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html b/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html new file mode 100644 index 0000000000..5f60599dfc --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_extra_text_markup.html @@ -0,0 +1,136 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10nOverlays Localized text markup</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + /* global L10nOverlays */ + "use strict"; + + function elem(name) { + return function(str) { + const element = document.createElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + // Localized text markup + { + // allowed element + const element = elem("div")`Foo`; + const translation = { + value: "FOO <em>BAR</em> BAZ", + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, "FOO <em>BAR</em> BAZ"); + } + + { + // forbidden element + const element = elem("div")`Foo`; + const translation = { + value: 'FOO <img src="img.png" />', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, "FOO "); + } + + { + // forbiden element with text + const element = elem("div")`Foo`; + const translation = { + value: "FOO <a>a</a>", + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, "FOO a"); + } + + { + // nested HTML is forbidden + const element = elem("div")`Foo`; + const translation = { + value: "FOO <em><strong>BAR</strong></em> BAZ", + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, "FOO <em>BAR</em> BAZ"); + } + + // Attributes of localized text markup + { + // allowed attribute + const element = elem("div")`Foo Bar`; + const translation = { + value: 'FOO <em title="BAR">BAR</em>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + 'FOO <em title="BAR">BAR</em>'); + } + + { + // forbidden attribute + const element = elem("div")`Foo Bar`; + const translation = { + value: 'FOO <em class="BAR" title="BAR">BAR</em>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + 'FOO <em title="BAR">BAR</em>'); + } + + { + // attributes do not leak on first translation + const element = elem("div")` + <em title="Foo">Foo</a>`; + const translation = { + value: "<em>FOO</em>", + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + "<em>FOO</em>"); + } + + { + // attributes do not leak on retranslation + const element = elem("div")``; + const translationA = { + value: '<em title="FOO A">FOO A</em>', + attributes: null, + }; + const translationB = { + value: "<em>FOO B</em>", + attributes: null, + }; + + translateElement(element, translationA); + is(element.innerHTML, + '<em title="FOO A">FOO A</em>'); + translateElement(element, translationB); + is(element.innerHTML, + "<em>FOO B</em>"); + } + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html b/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html new file mode 100644 index 0000000000..dba5b6e633 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_functional_children.html @@ -0,0 +1,344 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10nOverlays functional children test</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + /* global L10nOverlays */ + "use strict"; + + function elem(name) { + return function(str) { + const element = document.createElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + // Child without name + { + // in source + const element = elem("div")` + <a>Foo</a>`; + const translation = { + value: "FOO", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + { + // in translation + const element = elem("div")`Foo`; + const translation = { + value: "<a>FOO</a>", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + { + // in both + const element = elem("div")` + <a>Foo</a>`; + const translation = { + value: "<a>FOO</a>", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + // Child with name + { + // in source + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: "<a>FOO</a>", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + { + // in translation + const element = elem("div")` + <a>Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + { + // in both + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, '<a data-l10n-name="foo">FOO</a>'); + } + + { + // translation without text content + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo"></a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, '<a data-l10n-name="foo"></a>'); + } + + { + // different names + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="bar">BAR</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "BAR"); + } + + { + // of different types + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<div data-l10n-name="foo">FOO</div>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, "FOO"); + } + + { + // used twice + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO 1</a> <a data-l10n-name="foo">FOO 2</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, '<a data-l10n-name="foo">FOO 1</a> FOO 2'); + } + + // Two named children + { + // in order + const element = elem("div")` + <a data-l10n-name="foo">Foo</a> + <a data-l10n-name="bar">Bar</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>'); + } + + { + // out of order + const element = elem("div")` + <a data-l10n-name="foo">Foo</a> + <a data-l10n-name="bar">Bar</a>`; + const translation = { + value: '<a data-l10n-name="bar">BAR</a><a data-l10n-name="foo">FOO</a>', + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, '<a data-l10n-name="bar">BAR</a><a data-l10n-name="foo">FOO</a>'); + } + + { + // nested in source + const element = elem("div")` + <a data-l10n-name="foo"> + Foo 1 + <a data-l10n-name="bar">Bar</a> + Foo 2 + </a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>', + attributes: null, + }; + translateElement(element, translation); + is( + element.innerHTML, + '<a data-l10n-name="foo">FOO</a><a data-l10n-name="bar">BAR</a>' + ); + } + + { + // nested in translation + const element = elem("div")` + <div data-l10n-name="foo">Foo</div> + <div data-l10n-name="bar">Bar</div>`; + const translation = { + value: '<div data-l10n-name="foo">FOO 1 <div data-l10n-name="bar">BAR</div> FOO 2</div>', + attributes: null, + }; + translateElement(element, translation); + is( + element.innerHTML, + '<div data-l10n-name="foo">FOO 1 BAR FOO 2</div>' + ); + } + + // Child attributes + { + // functional attribute in source + const element = elem("div")` + <a data-l10n-name="foo" class="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" class="foo">FOO</a>'); + } + + { + // functional attribute in translation + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" class="bar">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo">FOO</a>'); + } + + { + // functional attribute in both + const element = elem("div")` + <a data-l10n-name="foo" class="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" class="bar">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" class="foo">FOO</a>'); + } + + { + // localizable attribute in source + const element = elem("div")` + <a data-l10n-name="foo" title="Foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo">FOO</a>'); + } + + { + // localizable attribute in translation + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" title="FOO">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" title="FOO">FOO</a>'); + } + + { + // localizable attribute in both + const element = elem("div")` + <a data-l10n-name="foo" title="Foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" title="BAR">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" title="BAR">FOO</a>'); + } + + { + // localizable attribute does not leak on retranslation + const element = elem("div")` + <a data-l10n-name="foo">Foo</a>`; + const translationA = { + value: '<a data-l10n-name="foo" title="FOO A">FOO A</a>', + attributes: null, + }; + const translationB = { + value: '<a data-l10n-name="foo">FOO B</a>', + attributes: null, + }; + + translateElement(element, translationA); + is(element.innerHTML, + '<a data-l10n-name="foo" title="FOO A">FOO A</a>'); + translateElement(element, translationB); + is(element.innerHTML, + '<a data-l10n-name="foo">FOO B</a>'); + } + + // Child attributes overrides + { + // the source can override child's attributes + const element = elem("div")` + <a data-l10n-name="foo" data-l10n-attrs="class" class="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" class="FOO">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" data-l10n-attrs="class" class="FOO">FOO</a>'); + } + + { + // the translation cannot override child's attributes + const element = elem("div")` + <a data-l10n-name="foo" class="foo">Foo</a>`; + const translation = { + value: '<a data-l10n-name="foo" data-l10n-attrs="class" class="FOO">FOO</a>', + attributes: null, + }; + + translateElement(element, translation); + is(element.innerHTML, + '<a data-l10n-name="foo" class="foo">FOO</a>'); + } + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml b/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml new file mode 100644 index 0000000000..494958c573 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_l10n_overlays.xhtml @@ -0,0 +1,87 @@ +<?xml version="1.0"?> +<?xml-stylesheet href="chrome://global/skin" type="text/css"?> +<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" + type="text/css"?> + + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + title="Testing DocumentL10n in XUL environment"> + + <linkset> + <html:link rel="localization" href="toolkit/about/aboutAddons.ftl"/> + </linkset> + + <script type="application/javascript" + src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" /> + <script type="application/javascript"> + <![CDATA[ + /* global L10nOverlays */ + + function elem(name) { + return function(str) { + const element = document.createXULElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + SimpleTest.waitForExplicitFinish(); + + { + // Allowed attribute + const element = elem("description")``; + const translation = { + value: null, + attributes: [ + {name: "title", value: "FOO"}, + ], + }; + translateElement(element, translation); + is(element.outerHTML, '<description xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" title="FOO"/>'); + } + + document.addEventListener("DOMContentLoaded", () => { + { + // Handle HTML translation + const element = document.getElementById("test2"); + const translation = { + value: "This is <a data-l10n-name=\"link\">a link</a>.", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, 'This is <html:a xmlns:html="http://www.w3.org/1999/xhtml" data-l10n-name=\"link\" href="https://www.mozilla.org\">a link</html:a>.'); + } + + { + // Don't handle XUL translation + // + // Current iteration of L10nOverlays will replace + // XUL elements from translation with text. + // + // See bug 1545704 for details. + const element = document.getElementById("test3"); + const translation = { + value: "This is <description data-l10n-name=\"desc\">a desc</description>.", + attributes: null, + }; + translateElement(element, translation); + is(element.innerHTML, 'This is a desc.'); + } + SimpleTest.finish(); + }, {once: true}); + + ]]> + </script> + + <description id="test2"> + <html:a data-l10n-name="link" href="https://www.mozilla.org"/> + </description> + + <box id="test3"> + <description data-l10n-name="desc"/> + </box> +</window> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html new file mode 100644 index 0000000000..ecaefbd68d --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id.html @@ -0,0 +1,57 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Amount of mutations generated from DOM Overlays</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="toolkit/about/aboutTelemetry.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + let config = { + attributes: true, + attributeOldValue: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }; + let allMutations = []; + + document.addEventListener("DOMContentLoaded", async function() { + await document.l10n.ready; + + let inputElem = document.getElementById("search-input"); + + // Test for initial localization applied. + is(!!inputElem.getAttribute("placeholder").length, true); + + let observer = new MutationObserver((mutations) => { + for (let mutation of mutations) { + allMutations.push(mutation); + } + }); + observer.observe(inputElem, config); + + document.l10n.setAttributes(inputElem, "about-telemetry-filter-all-placeholder"); + + // Due to the async iteractions between nsINode.localize + // and DOMLocalization, we'll need to wait two frames + // to verify that no mutations happened. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Since the l10n-id is the same as the previous one + // no mutation should happen in result. + is(allMutations.length, 0); + SimpleTest.finish(); + }); + }); + }, { once: true}); + </script> +</head> +<body> + <input id="search-input" data-l10n-id="about-telemetry-filter-all-placeholder"></input> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html new file mode 100644 index 0000000000..e43c394970 --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_same_id_args.html @@ -0,0 +1,57 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test Amount of mutations generated from DOM Overlays</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <link rel="localization" href="toolkit/about/aboutTelemetry.ftl"/> + <script type="application/javascript"> + "use strict"; + SimpleTest.waitForExplicitFinish(); + + let config = { + attributes: true, + attributeOldValue: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true, + }; + let allMutations = []; + + document.addEventListener("DOMContentLoaded", async function() { + await document.l10n.ready; + + let inputElem = document.getElementById("search-input"); + + // Test for initial localization applied. + is(!!inputElem.getAttribute("placeholder").length, true); + + let observer = new MutationObserver((mutations) => { + for (let mutation of mutations) { + allMutations.push(mutation); + } + }); + observer.observe(inputElem, config); + + document.l10n.setAttributes(inputElem, "about-telemetry-filter-placeholder", {selectedTitle: "Test"}); + + // Due to the async iteractions between nsINode.localize + // and DOMLocalization, we'll need to wait two frames + // to verify that no mutations happened. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Since the l10n-id is the same as the previous one + // no mutation should happen in result. + is(allMutations.length, 0); + SimpleTest.finish(); + }); + }); + }, { once: true}); + </script> +</head> +<body> + <input id="search-input" data-l10n-id="about-telemetry-filter-placeholder" data-l10n-args='{"selectedTitle":"Test"}'></input> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html b/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html new file mode 100644 index 0000000000..1c2fab7ade --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_text_children.html @@ -0,0 +1,74 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10nOverlays Text-semantic argument elements</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + /* global L10nOverlays */ + "use strict"; + + function elem(name) { + return function(str) { + const element = document.createElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + { + // without data-l10n-name + const element = elem("div")` + <em class="bar"></em>`; + const translation = { + value: '<em title="FOO">FOO</em>', + attributes: null, + }; + + translateElement(element, translation); + is( + element.innerHTML, + '<em title="FOO">FOO</em>' + ); + } + + { + // mismatched types + const element = elem("div")` + <button data-l10n-name="foo"></button>`; + const translation = { + value: '<em data-l10n-name="foo" title="FOO">FOO</em>', + attributes: null, + }; + + translateElement(element, translation); + is( + element.innerHTML, + "FOO" + ); + } + + { + // types and names mismatch + const element = elem("div")` + <em data-l10n-name="foo" class="foo"></em>`; + const translation = { + value: '<em data-l10n-name="foo" title="FOO">FOO</em>', + attributes: null, + }; + + translateElement(element, translation); + is( + element.innerHTML, + '<em data-l10n-name="foo" class="foo" title="FOO">FOO</em>' + ); + } + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/l10n_overlays/test_title.html b/dom/l10n/tests/mochitest/l10n_overlays/test_title.html new file mode 100644 index 0000000000..4571589b8e --- /dev/null +++ b/dom/l10n/tests/mochitest/l10n_overlays/test_title.html @@ -0,0 +1,60 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test L10nOverlays Special treatment of the title element</title> + <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> + <script type="application/javascript"> + /* global L10nOverlays */ + "use strict"; + + function elem(name) { + return function(str) { + const element = document.createElement(name); + // eslint-disable-next-line no-unsanitized/property + element.innerHTML = str; + return element; + }; + } + + const { translateElement } = L10nOverlays; + + { + // Text is fine. + const element = elem("title")``; + const translation = { + value: 'Text', + attributes: null, + }; + + translateElement(element, translation); + is( + element.innerHTML, + 'Text' + ); + } + + { + // Markup is ignored. + const element = elem("title")``; + const translation = { + value: '<em>Markup</em>', + attributes: null, + }; + + translateElement(element, translation); + is( + element.textContent, + '<em>Markup</em>' + ); + is( + element.innerHTML, + '<em>Markup</em>' + ); + } + </script> +</head> +<body> +</body> +</html> diff --git a/dom/l10n/tests/mochitest/mochitest.ini b/dom/l10n/tests/mochitest/mochitest.ini new file mode 100644 index 0000000000..82af57c47c --- /dev/null +++ b/dom/l10n/tests/mochitest/mochitest.ini @@ -0,0 +1 @@ +[document_l10n/test_unpriv_iframe.html] |