diff --git a/modules/@angular/compiler/src/i18n/digest.ts b/modules/@angular/compiler/src/i18n/digest.ts index dd37340a9d..7159df5bb1 100644 --- a/modules/@angular/compiler/src/i18n/digest.ts +++ b/modules/@angular/compiler/src/i18n/digest.ts @@ -97,7 +97,7 @@ export function sha1(str: string): string { hex += (b >>> 4 & 0x0f).toString(16) + (b & 0x0f).toString(16); } - return hex; + return hex.toLowerCase(); } function utf8Encode(str: string): string { diff --git a/modules/@angular/compiler/src/i18n/i18n_ast.ts b/modules/@angular/compiler/src/i18n/i18n_ast.ts index 1a2806d96e..7ed07b6b87 100644 --- a/modules/@angular/compiler/src/i18n/i18n_ast.ts +++ b/modules/@angular/compiler/src/i18n/i18n_ast.ts @@ -9,8 +9,17 @@ import {ParseSourceSpan} from '../parse_util'; export class Message { + /** + * @param nodes message AST + * @param placeholders maps placeholder names to static content + * @param placeholderToMsgIds maps placeholder names to translatable message IDs (used for ICU + * messages) + * @param meaning + * @param description + */ constructor( - public nodes: Node[], public placeholders: {[name: string]: string}, public meaning: string, + public nodes: Node[], public placeholders: {[name: string]: string}, + public placeholderToMsgIds: {[name: string]: string}, public meaning: string, public description: string) {} } diff --git a/modules/@angular/compiler/src/i18n/i18n_parser.ts b/modules/@angular/compiler/src/i18n/i18n_parser.ts index 7d58dac71b..55b0412bb6 100644 --- a/modules/@angular/compiler/src/i18n/i18n_parser.ts +++ b/modules/@angular/compiler/src/i18n/i18n_parser.ts @@ -12,6 +12,7 @@ import * as html from '../ml_parser/ast'; import {getHtmlTagDefinition} from '../ml_parser/html_tags'; import {InterpolationConfig} from '../ml_parser/interpolation_config'; import {ParseSourceSpan} from '../parse_util'; +import {digestMessage} from './digest'; import * as i18n from './i18n_ast'; import {PlaceholderRegistry} from './serializers/placeholder'; @@ -19,7 +20,7 @@ import {PlaceholderRegistry} from './serializers/placeholder'; const _expParser = new ExpressionParser(new ExpressionLexer()); /** - * Returns a function converting html Messages to i18n Messages given an interpolationConfig + * Returns a function converting html nodes to an i18n Message given an interpolationConfig */ export function createI18nMessageFactory(interpolationConfig: InterpolationConfig): ( nodes: html.Node[], meaning: string, description: string) => i18n.Message { @@ -34,6 +35,7 @@ class _I18nVisitor implements html.Visitor { private _icuDepth: number; private _placeholderRegistry: PlaceholderRegistry; private _placeholderToContent: {[name: string]: string}; + private _placeholderToIds: {[name: string]: string}; constructor( private _expressionParser: ExpressionParser, @@ -44,10 +46,12 @@ class _I18nVisitor implements html.Visitor { this._icuDepth = 0; this._placeholderRegistry = new PlaceholderRegistry(); this._placeholderToContent = {}; + this._placeholderToIds = {}; const i18nodes: i18n.Node[] = html.visitAll(this, nodes, {}); - return new i18n.Message(i18nodes, this._placeholderToContent, meaning, description); + return new i18n.Message( + i18nodes, this._placeholderToContent, this._placeholderToIds, meaning, description); } visitElement(el: html.Element, context: any): i18n.Node { @@ -99,9 +103,14 @@ class _I18nVisitor implements html.Visitor { return i18nIcu; } - // else returns a placeholder + // Else returns a placeholder + // ICU placeholders should not be replaced with their original content but with the their + // translations. We need to create a new visitor (they are not re-entrant) to compute the + // message id. + // TODO(vicb): add a html.Node -> i18n.Message cache to avoid having to re-create the msg const phName = this._placeholderRegistry.getPlaceholderName('ICU', icu.sourceSpan.toString()); - this._placeholderToContent[phName] = icu.sourceSpan.toString(); + const visitor = new _I18nVisitor(this._expressionParser, this._interpolationConfig); + this._placeholderToIds[phName] = digestMessage(visitor.toI18nMessage([icu], '', '')); return new i18n.IcuPlaceholder(i18nIcu, phName, icu.sourceSpan); } diff --git a/modules/@angular/compiler/src/i18n/message_bundle.ts b/modules/@angular/compiler/src/i18n/message_bundle.ts index e3b7dbb0d1..a3918e8100 100644 --- a/modules/@angular/compiler/src/i18n/message_bundle.ts +++ b/modules/@angular/compiler/src/i18n/message_bundle.ts @@ -45,5 +45,7 @@ export class MessageBundle { (message) => { this._messageMap[digestMessage(message)] = message; }); } + getMessageMap(): {[id: string]: Message} { return this._messageMap; } + write(serializer: Serializer): string { return serializer.write(this._messageMap); } } diff --git a/modules/@angular/compiler/src/i18n/serializers/serializer.ts b/modules/@angular/compiler/src/i18n/serializers/serializer.ts index ad8565c204..3c5f94d042 100644 --- a/modules/@angular/compiler/src/i18n/serializers/serializer.ts +++ b/modules/@angular/compiler/src/i18n/serializers/serializer.ts @@ -8,10 +8,34 @@ import * as html from '../../ml_parser/ast'; import * as i18n from '../i18n_ast'; +import {MessageBundle} from '../message_bundle'; export interface Serializer { write(messageMap: {[id: string]: i18n.Message}): string; - load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): - {[id: string]: html.Node[]}; + load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]}; +} + +// Generate a map of placeholder to content indexed by message ids +export function extractPlaceholders(messageBundle: MessageBundle) { + const messageMap = messageBundle.getMessageMap(); + let placeholders: {[id: string]: {[name: string]: string}} = {}; + + Object.keys(messageMap).forEach(msgId => { + placeholders[msgId] = messageMap[msgId].placeholders; + }); + + return placeholders; +} + +// Generate a map of placeholder to message ids indexed by message ids +export function extractPlaceholderToIds(messageBundle: MessageBundle) { + const messageMap = messageBundle.getMessageMap(); + let placeholderToIds: {[id: string]: {[name: string]: string}} = {}; + + Object.keys(messageMap).forEach(msgId => { + placeholderToIds[msgId] = messageMap[msgId].placeholderToMsgIds; + }); + + return placeholderToIds; } \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/serializers/xmb.ts b/modules/@angular/compiler/src/i18n/serializers/xmb.ts index fc87064e05..a0b26e0c87 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xmb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xmb.ts @@ -9,6 +9,7 @@ import {ListWrapper} from '../../facade/collection'; import * as html from '../../ml_parser/ast'; import * as i18n from '../i18n_ast'; +import {MessageBundle} from '../message_bundle'; import {Serializer} from './serializer'; import * as xml from './xml_helper'; @@ -70,8 +71,7 @@ export class Xmb implements Serializer { ]); } - load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): - {[id: string]: html.Node[]} { + load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: html.Node[]} { throw new Error('Unsupported'); } } diff --git a/modules/@angular/compiler/src/i18n/serializers/xtb.ts b/modules/@angular/compiler/src/i18n/serializers/xtb.ts index 485b00d2b6..3c12f95c04 100644 --- a/modules/@angular/compiler/src/i18n/serializers/xtb.ts +++ b/modules/@angular/compiler/src/i18n/serializers/xtb.ts @@ -12,9 +12,10 @@ import {InterpolationConfig} from '../../ml_parser/interpolation_config'; import {XmlParser} from '../../ml_parser/xml_parser'; import {ParseError} from '../../parse_util'; import * as i18n from '../i18n_ast'; +import {MessageBundle} from '../message_bundle'; import {I18nError} from '../parse_util'; -import {Serializer} from './serializer'; +import {Serializer, extractPlaceholderToIds, extractPlaceholders} from './serializer'; const _TRANSLATIONS_TAG = 'translationbundle'; const _TRANSLATION_TAG = 'translation'; @@ -25,8 +26,7 @@ export class Xtb implements Serializer { write(messageMap: {[id: string]: i18n.Message}): string { throw new Error('Unsupported'); } - load(content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}): - {[id: string]: ml.Node[]} { + load(content: string, url: string, messageBundle: MessageBundle): {[id: string]: ml.Node[]} { // Parse the xtb file into xml nodes const result = new XmlParser().parse(content, url); @@ -35,7 +35,7 @@ export class Xtb implements Serializer { } // Replace the placeholders, messages are now string - const {messages, errors} = new _Serializer().parse(result.rootNodes, placeholders); + const {messages, errors} = new _Serializer().parse(result.rootNodes, messageBundle); if (errors.length) { throw new Error(`xtb parse errors:\n${errors.join('\n')}`); @@ -44,7 +44,7 @@ export class Xtb implements Serializer { // Convert the string messages to html ast // TODO(vicb): map error message back to the original message in xtb let messageMap: {[id: string]: ml.Node[]} = {}; - let parseErrors: ParseError[] = []; + const parseErrors: ParseError[] = []; Object.keys(messages).forEach((id) => { const res = this._htmlParser.parse(messages[id], url, true, this._interpolationConfig); @@ -61,24 +61,58 @@ export class Xtb implements Serializer { } class _Serializer implements ml.Visitor { - private _messages: {[id: string]: string}; + private _messageNodes: [string, ml.Node[]][]; + private _translatedMessages: {[id: string]: string}; private _bundleDepth: number; private _translationDepth: number; private _errors: I18nError[]; - private _placeholders: {[id: string]: {[name: string]: string}}; - private _currentPlaceholders: {[name: string]: string}; + private _placeholders: {[name: string]: string}; + private _placeholderToIds: {[name: string]: string}; - parse(nodes: ml.Node[], _placeholders: {[id: string]: {[name: string]: string}}): + parse(nodes: ml.Node[], messageBundle: MessageBundle): {messages: {[k: string]: string}, errors: I18nError[]} { - this._messages = {}; + this._messageNodes = []; + this._translatedMessages = {}; this._bundleDepth = 0; this._translationDepth = 0; this._errors = []; - this._placeholders = _placeholders; + // Find all messages ml.visitAll(this, nodes, null); - return {messages: this._messages, errors: this._errors}; + const messageMap = messageBundle.getMessageMap(); + const placeholders = extractPlaceholders(messageBundle); + const placeholderToIds = extractPlaceholderToIds(messageBundle); + + this._messageNodes + .filter(message => { + // Remove any messages that is not present in the source message bundle. + return messageMap.hasOwnProperty(message[0]); + }) + .sort((a, b) => { + // Because there could be no ICU placeholders inside an ICU message, + // we do not need to take into account the `placeholderToMsgIds` of the referenced + // messages, those would always be empty + // TODO(vicb): overkill - create 2 buckets and [...woDeps, ...wDeps].process() + if (Object.keys(messageMap[a[0]].placeholderToMsgIds).length == 0) { + return -1; + } + + if (Object.keys(messageMap[b[0]].placeholderToMsgIds).length == 0) { + return 1; + } + + return 0; + }) + .forEach(message => { + const id = message[0]; + this._placeholders = placeholders[id] || {}; + this._placeholderToIds = placeholderToIds[id] || {}; + // TODO(vicb): make sure there is no `_TRANSLATIONS_TAG` nor `_TRANSLATION_TAG` + this._translatedMessages[id] = ml.visitAll(this, message[1]).join(''); + }); + + return {messages: this._translatedMessages, errors: this._errors}; } visitElement(element: ml.Element, context: any): any { @@ -101,8 +135,11 @@ class _Serializer implements ml.Visitor { if (!idAttr) { this._addError(element, `<${_TRANSLATION_TAG}> misses the "id" attribute`); } else { - this._currentPlaceholders = this._placeholders[idAttr.value] || {}; - this._messages[idAttr.value] = ml.visitAll(this, element.children).join(''); + // ICU placeholders are reference to other messages. + // The referenced message might not have been decoded yet. + // We need to have all messages available to make sure deps are decoded first. + // TODO(vicb): report an error on duplicate id + this._messageNodes.push([idAttr.value, element.children]); } this._translationDepth--; break; @@ -112,11 +149,18 @@ class _Serializer implements ml.Visitor { if (!nameAttr) { this._addError(element, `<${_PLACEHOLDER_TAG}> misses the "name" attribute`); } else { - if (this._currentPlaceholders.hasOwnProperty(nameAttr.value)) { - return this._currentPlaceholders[nameAttr.value]; + const name = nameAttr.value; + if (this._placeholders.hasOwnProperty(name)) { + return this._placeholders[name]; } + if (this._placeholderToIds.hasOwnProperty(name) && + this._translatedMessages.hasOwnProperty(this._placeholderToIds[name])) { + return this._translatedMessages[this._placeholderToIds[name]]; + } + // TODO(vicb): better error message for when + // !this._translatedMessages.hasOwnProperty(this._placeholderToIds[name]) this._addError( - element, `The placeholder "${nameAttr.value}" does not exists in the source message`); + element, `The placeholder "${name}" does not exists in the source message`); } break; diff --git a/modules/@angular/compiler/src/i18n/translation_bundle.ts b/modules/@angular/compiler/src/i18n/translation_bundle.ts index c8072abb10..28cbe0587f 100644 --- a/modules/@angular/compiler/src/i18n/translation_bundle.ts +++ b/modules/@angular/compiler/src/i18n/translation_bundle.ts @@ -7,8 +7,9 @@ */ import * as html from '../ml_parser/ast'; -import {Serializer} from './serializers/serializer'; +import {MessageBundle} from './message_bundle'; +import {Serializer} from './serializers/serializer'; /** * A container for translated messages @@ -16,10 +17,9 @@ import {Serializer} from './serializers/serializer'; export class TranslationBundle { constructor(private _messageMap: {[id: string]: html.Node[]} = {}) {} - static load( - content: string, url: string, placeholders: {[id: string]: {[name: string]: string}}, - serializer: Serializer): TranslationBundle { - return new TranslationBundle(serializer.load(content, url, placeholders)); + static load(content: string, url: string, messageBundle: MessageBundle, serializer: Serializer): + TranslationBundle { + return new TranslationBundle(serializer.load(content, url, messageBundle)); } get(id: string): html.Node[] { return this._messageMap[id]; } diff --git a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts index fbb3e941a9..7a0063ed35 100644 --- a/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts +++ b/modules/@angular/compiler/test/i18n/i18n_parser_spec.ts @@ -272,12 +272,17 @@ export function main() { [['{count, plural, =1 {[1]}}'], '', ''], ]); - expect(_humanizePlaceholders(html)).toEqual([ - 'ICU={count, plural, =0 {0}}, ICU_1={count, plural, =1 {1}}', + // ICU message placeholders are reference to translations. + // As such they have no static content but refs to message ids. + expect(_humanizePlaceholders(html)).toEqual(['', '', '', '']); + + expect(_humanizePlaceholdersToIds(html)).toEqual([ + 'ICU=f0f76923009914f1b05f41042a5c7231b9496504, ICU_1=73693d1f78d0fc882f0bcbce4cb31a0aa1995cfe', '', '', '', ]); + }); }); }); @@ -303,6 +308,17 @@ function _humanizePlaceholders( // clang-format on } +function _humanizePlaceholdersToIds( + html: string, implicitTags: string[] = [], + implicitAttrs: {[k: string]: string[]} = {}): string[] { + // clang-format off + // https://github.com/angular/clang-format/issues/35 + return _extractMessages(html, implicitTags, implicitAttrs).map( + msg => Object.keys(msg.placeholderToMsgIds).map(k => `${k}=${msg.placeholderToMsgIds[k]}`).join(', ')); + // clang-format on +} + + function _extractMessages( html: string, implicitTags: string[] = [], implicitAttrs: {[k: string]: string[]} = {}): Message[] { diff --git a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts index e6cebae272..0c49bbf951 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xmb_spec.ts @@ -56,8 +56,8 @@ export function main(): void { it('should throw when trying to load an xmb file', () => { expect(() => { const serializer = new Xmb(); - serializer.load(XMB, 'url', {}); - }).toThrow(); + serializer.load(XMB, 'url', null); + }).toThrowError(/Unsupported/); }); }); } diff --git a/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts b/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts index 8828e7e4fe..f8f8e97a84 100644 --- a/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts +++ b/modules/@angular/compiler/test/i18n/serializers/xtb_spec.ts @@ -6,10 +6,11 @@ * found in the LICENSE file at https://angular.io/license */ -import {Xtb} from '@angular/compiler/src/i18n/serializers/xtb'; import {escapeRegExp} from '@angular/core/src/facade/lang'; import {beforeEach, ddescribe, describe, expect, iit, inject, it, xdescribe, xit} from '@angular/core/testing/testing_internal'; +import {MessageBundle} from '../../../src/i18n/message_bundle'; +import {Xtb} from '../../../src/i18n/serializers/xtb'; import {HtmlParser} from '../../../src/ml_parser/html_parser'; import {DEFAULT_INTERPOLATION_CONFIG} from '../../../src/ml_parser/interpolation_config'; import {serializeNodes} from '../../ml_parser/ast_serializer_spec'; @@ -17,21 +18,28 @@ import {serializeNodes} from '../../ml_parser/ast_serializer_spec'; export function main(): void { describe('XTB serializer', () => { let serializer: Xtb; + let htmlParser: HtmlParser; - function loadAsText(content: string, placeholders: {[id: string]: {[name: string]: string}}): - {[id: string]: string} { - const asAst = serializer.load(content, 'url', placeholders); + function loadAsText(template: string, xtb: string): {[id: string]: string} { + let messageBundle = new MessageBundle(htmlParser, [], {}); + messageBundle.updateFromTemplate(template, 'url', DEFAULT_INTERPOLATION_CONFIG); + + const asAst = serializer.load(xtb, 'url', messageBundle); let asText: {[id: string]: string} = {}; Object.keys(asAst).forEach(id => { asText[id] = serializeNodes(asAst[id]).join(''); }); return asText; } - beforeEach(() => { serializer = new Xtb(new HtmlParser(), DEFAULT_INTERPOLATION_CONFIG); }); - + beforeEach(() => { + htmlParser = new HtmlParser(); + serializer = new Xtb(htmlParser, DEFAULT_INTERPOLATION_CONFIG); + }); describe('load', () => { it('should load XTB files with a doctype', () => { + const HTML = `
bar
`; + const XTB = ` @@ -43,62 +51,75 @@ export function main(): void { ]> - bar + rab `; - expect(loadAsText(XTB, {})).toEqual({foo: 'bar'}); + expect(loadAsText(HTML, XTB)).toEqual({'28a86c8a00ae573b2bac698d6609316dc7b4a226': 'rab'}); }); it('should load XTB files without placeholders', () => { + const HTML = `
bar
`; + const XTB = ` - bar + rab `; - expect(loadAsText(XTB, {})).toEqual({foo: 'bar'}); + expect(loadAsText(HTML, XTB)).toEqual({'28a86c8a00ae573b2bac698d6609316dc7b4a226': 'rab'}); }); it('should load XTB files with placeholders', () => { + const HTML = `

bar

`; + const XTB = ` - bar + rab `; - expect(loadAsText(XTB, {foo: {PLACEHOLDER: '!'}})).toEqual({foo: 'bar!!'}); + expect(loadAsText(HTML, XTB)).toEqual({ + '7de4d8ff1e42b7b31da6204074818236a9a5317f': '

rab

' + }); + }); + + it('should replace ICU placeholders with their translations', () => { + const HTML = `
-{ count, plural, =0 {

bar

}}-
`; + + const XTB = ` + + ** + { count, plural, =1 {rab}} +`; + + expect(loadAsText(HTML, XTB)).toEqual({ + 'eb404e202fed4846e25e7d9ac1fcb719fe4da257': `*{ count, plural, =1 {

rab

}}*`, + 'fc92b9b781194a02ab773129c8c5a7fc0735efd7': `{ count, plural, =1 {

rab

}}`, + }); }); it('should load complex XTB files', () => { + const HTML = ` +
foo bar {{ a + b }}
+
{ count, plural, =0 {

bar

}}
+
foo
+
{ count, plural, =0 {{ sex, gender, other {

bar

}} }}
`; + const XTB = ` - translatable element <b>with placeholders</b> - { count, plural, =0 {<p>test</p>}} - foo - { count, plural, =0 {{ sex, gender, other {<p>deeply nested</p>}} }} + rab oof + { count, plural, =1 {rab}} + oof + { count, plural, =1 {{ sex, gender, male {rab}} }} `; - const PLACEHOLDERS = { - a: { - START_BOLD_TEXT: '', - CLOSE_BOLD_TEXT: '', - INTERPOLATION: '{{ a + b }}', - }, - b: { - START_PARAGRAPH: '

', - CLOSE_PARAGRAPH: '

', - }, - d: { - START_PARAGRAPH: '

', - CLOSE_PARAGRAPH: '

', - }, - }; - - expect(loadAsText(XTB, PLACEHOLDERS)).toEqual({ - a: 'translatable element with placeholders {{ a + b }}', - b: '{ count, plural, =0 {

test

}}', - c: 'foo', - d: '{ count, plural, =0 {{ sex, gender, other {

deeply nested

}} }}', + expect(loadAsText(HTML, XTB)).toEqual({ + '7103b4b13b616270a0044efade97d8b4f96f2ca6': `{{ a + b }}rab oof`, + 'fc92b9b781194a02ab773129c8c5a7fc0735efd7': `{ count, plural, =1 {

rab

}}`, + 'db3e0a6a5a96481f60aec61d98c3eecddef5ac23': `oof`, + 'e3bf2d706c3da16ce05658e07f62f0519f7c561c': + `{ count, plural, =1 {{ sex, gender, male {

rab

}} }}`, }); }); + }); describe('errors', () => { @@ -107,61 +128,54 @@ export function main(): void { ''; expect(() => { - serializer.load(XTB, 'url', {}); + loadAsText('', XTB); }).toThrowError(/ elements can not be nested/); }); - it('should throw on nested ', () => { - const XTB = ` - - - - -`; - - expect(() => { - serializer.load(XTB, 'url', {}); - }).toThrowError(/ elements can not be nested/); - }); - it('should throw when a has no id attribute', () => { const XTB = ` `; expect(() => { - serializer.load(XTB, 'url', {}); + loadAsText('', XTB); }).toThrowError(/ misses the "id" attribute/); }); it('should throw when a placeholder has no name attribute', () => { + const HTML = '
give me a message
'; + const XTB = ` - + `; - expect(() => { - serializer.load(XTB, 'url', {}); - }).toThrowError(/ misses the "name" attribute/); + expect(() => { loadAsText(HTML, XTB); }).toThrowError(/ misses the "name" attribute/); }); it('should throw when a placeholder is not present in the source message', () => { - const XTB = ` - + const HTML = `
bar
`; + + const XTB = ` + + `; expect(() => { - serializer.load(XTB, 'url', {}); + loadAsText(HTML, XTB); }).toThrowError(/The placeholder "UNKNOWN" does not exists in the source message/); }); }); it('should throw when the translation results in invalid html', () => { - const XTB = ` - foobar + const HTML = `

bar

`; + + const XTB = ` + + rab `; expect(() => { - serializer.load(XTB, 'url', {fail: {CLOSE_P: '

'}}); + loadAsText(HTML, XTB); }).toThrowError(/xtb parse errors:\nUnexpected closing tag "p"/); }); @@ -170,11 +184,11 @@ export function main(): void { const XTB = ``; expect(() => { - serializer.load(XTB, 'url', {}); + loadAsText('', XTB); }).toThrowError(new RegExp(escapeRegExp(`Unexpected tag ("[ERROR ->]")`))); }); - it('should throw when trying to save an xmb file', - () => { expect(() => { serializer.write({}); }).toThrow(); }); + it('should throw when trying to save an xtb file', + () => { expect(() => { serializer.write({}); }).toThrowError(/Unsupported/); }); }); -} +} \ No newline at end of file