'use strict'; const assert = require('assert'); const { changeLanguageFromPage, _private } = require('../src/services/language_service'); class FakeElement { constructor({ aria = '', text = '', attrs = {}, box = { x: 0, y: 200, width: 48, height: 48 }, visible = true, disabled = false, hasSvg = false, onClick = null, onFill = null, children = null } = {}) { this.aria = aria; this.text = text; this.attrs = attrs; this.box = box; this.visible = visible; this.disabled = disabled; this.hasSvg = hasSvg; this.onClick = onClick; this.onFill = onFill; this.children = children; } async boundingBox() { return this.visible ? this.box : null; } async isVisible() { return this.visible; } async isDisabled() { return this.disabled; } async getAttribute(name) { return name === 'aria-label' ? this.aria : (this.attrs[name] ?? null); } async textContent() { return this.text; } async $(selector) { return selector === 'svg' && this.hasSvg ? {} : null; } async $$(selector) { return this.children ? this.children(selector) : []; } async click() { if (this.onClick) await this.onClick(); } async fill(value) { if (this.onFill) await this.onFill(value); } } class FakeLanguagePage { constructor(editorDelayMs = 2200, saveBehavior = 'apply') { this.editorDelayMs = editorDelayMs; this.saveBehavior = saveBehavior; this.editorVisibleAt = Infinity; this.mode = 'page'; this.query = ''; this.usSelected = false; this.currentTitle = 'Bahasa'; this.currentUrl = 'about:blank'; this.gotoCount = 0; this.cancelClicked = false; this._listeners = new Map(); this._mainFrame = { url: () => this.currentUrl }; this.headerButton = new FakeElement({ aria: 'Bantuan', box: { x: 900, y: 20, width: 48, height: 48 }, hasSvg: true, }); this.pencilButton = new FakeElement({ aria: 'Edit bahasa: Indonesia (Indonesia)', box: { x: 760, y: 412, width: 48, height: 48 }, hasSvg: true, onClick: () => { this.editorVisibleAt = Date.now() + this.editorDelayMs; }, }); this.input = new FakeElement({ box: { x: 300, y: 200, width: 400, height: 48 }, onFill: value => { this.query = value; }, }); this.englishOption = new FakeElement({ text: '\u200eEnglish\u200f', box: { x: 300, y: 300, width: 300, height: 48 }, onClick: () => { this.mode = 'region'; }, }); this.ukOption = new FakeElement({ text: 'United Kingdom', box: { x: 300, y: 300, width: 300, height: 48 } }); this.usOption = new FakeElement({ text: 'United States', box: { x: 300, y: 360, width: 300, height: 48 }, onClick: () => { this.usSelected = true; }, }); this.cancelButton = new FakeElement({ text: 'Batal', aria: 'Batalkan penambahan bahasa', box: { x: 400, y: 700, width: 80, height: 40 }, onClick: () => { this.cancelClicked = true; this.mode = 'saved'; }, }); this.backButton = new FakeElement({ text: 'Kembali', aria: 'Kembali ke pilihan bahasa', box: { x: 500, y: 700, width: 80, height: 40 }, }); this.saveButton = new FakeElement({ text: 'Simpan', aria: 'Simpan pilihan bahasa Anda', box: { x: 600, y: 700, width: 80, height: 40 }, onClick: () => { assert(this.usSelected, '保存前应先选择 United States'); this.mode = 'saved'; if (this.saveBehavior === 'apply') { this.currentTitle = 'Language'; this.pencilButton.aria = 'Edit language: English (United States)'; } if (this.saveBehavior === 'apply' || this.saveBehavior === 'close_without_apply') { this.emitMainNavigation('https://myaccount.google.com/language?refresh_reason=primary'); } }, }); this.dialogBeforeSelection = new FakeElement({ box: { x: 200, y: 100, width: 600, height: 700 }, children: selector => this.dialogChildren(selector, false), }); this.dialogAfterSelection = new FakeElement({ box: { x: 200, y: 100, width: 600, height: 700 }, children: selector => this.dialogChildren(selector, true), }); } editorVisible() { return this.mode !== 'saved' && Date.now() >= this.editorVisibleAt; } dialogChildren(selector, selectedHandle) { if (!this.editorVisible()) return []; // 模拟 Google 在选中地区后卸载旧 dialog 并挂载新节点。 if (selectedHandle !== this.usSelected) return []; if (selector === 'input') return this.mode === 'page' ? [this.input] : []; if (selector === '[role="option"]') { if (this.mode === 'page') return this.query === 'English' ? [this.englishOption] : []; if (this.mode === 'region') return [this.ukOption, this.usOption]; } if (selector === 'button') { if (this.mode === 'region') { if (this.saveBehavior === 'cancel_last') return [this.backButton, this.cancelButton]; return [this.cancelButton, this.backButton, this.saveButton]; } return [this.cancelButton, this.saveButton]; } return []; } on(event, handler) { if (!this._listeners.has(event)) this._listeners.set(event, new Set()); this._listeners.get(event).add(handler); } off(event, handler) { this._listeners.get(event)?.delete(handler); } listenerCount(event) { return this._listeners.get(event)?.size || 0; } mainFrame() { return this._mainFrame; } emitFrameNavigation(frame) { for (const handler of this._listeners.get('framenavigated') || []) handler(frame); } emitMainNavigation(url) { this.currentUrl = url; this.emitFrameNavigation(this._mainFrame); } async goto(url) { this.gotoCount++; this.emitMainNavigation(url); } url() { return this.currentUrl; } async title() { return this.currentTitle; } async waitForURL(predicate, { timeout = 1000 } = {}) { const matches = () => { try { return !!predicate(new URL(this.currentUrl)); } catch (_) { return false; } }; if (matches()) return; await new Promise((resolve, reject) => { const handler = () => { if (!matches()) return; clearTimeout(timer); this.off('framenavigated', handler); resolve(); }; const timer = setTimeout(() => { this.off('framenavigated', handler); reject(new Error(`waitForURL: Timeout ${timeout}ms exceeded`)); }, timeout); this.on('framenavigated', handler); }); } async waitForLoadState() { } async waitForTimeout(ms) { await new Promise(resolve => setTimeout(resolve, ms)); } async $$(selector) { if (selector === 'button') return [this.headerButton, this.pencilButton]; if (selector === '[role="dialog"]') { if (!this.editorVisible()) return []; return [this.usSelected ? this.dialogAfterSelection : this.dialogBeforeSelection]; } if (selector === 'input' || selector === '[role="option"]') return []; return []; } } class NavigationProbePage extends FakeLanguagePage { constructor(navigationMode) { super(0, 'apply'); this.navigationMode = navigationMode; this.currentUrl = navigationMode === 'old_primary' ? 'https://myaccount.google.com/language?refresh_reason=primary' : 'https://myaccount.google.com/language'; } async goto(url) { this.gotoCount++; if (this.navigationMode === 'expected_primary' || this.navigationMode === 'expected_abort') { const primary = 'https://myaccount.google.com/language?refresh_reason=primary'; this.pencilButton.aria = 'Edit language: English (United States)'; this.emitMainNavigation(primary); if (this.navigationMode === 'expected_abort') { throw new Error(`page.goto: net::ERR_ABORTED at ${url}`); } throw new Error(`page.goto: Navigation to "${url}" is interrupted by another navigation to "${primary}"`); } if (this.navigationMode === 'other_url') { const other = 'https://accounts.google.com/v3/signin/identifier'; this.emitMainNavigation(other); throw new Error(`page.goto: Navigation to "${url}" is interrupted by another navigation to "${other}"`); } if (this.navigationMode === 'timeout') { throw new Error('page.goto: Timeout 45000ms exceeded'); } if (this.navigationMode === 'proxy') { throw new Error('page.goto: net::ERR_TUNNEL_CONNECTION_FAILED'); } if (this.navigationMode === 'page_closed') { throw new Error('page.goto: Target page, context or browser has been closed'); } if (this.navigationMode === 'old_primary') { throw new Error(`page.goto: net::ERR_ABORTED at ${url}`); } if (this.navigationMode === 'subframe_primary') { const childFrame = { url: () => 'https://myaccount.google.com/language?refresh_reason=primary', }; this.emitFrameNavigation(childFrame); throw new Error(`page.goto: net::ERR_ABORTED at ${url}`); } if (this.navigationMode === 'resolved_login') { this.emitMainNavigation('https://accounts.google.com/v3/signin/identifier'); return; } if (this.navigationMode === 'resolved_chrome_error') { this.emitMainNavigation('chrome-error://chromewebdata/'); return; } return super.goto(url); } } (async () => { assert.strictEqual( _private.isEnglishUnitedStatesLabel('Edit bahasa: English (Amerika Serikat)'), true, '应识别印尼语地区名称', ); assert.strictEqual( _private.isEnglishUnitedStatesLabel('Ubah bahasa: Inggris (Amerika Serikat)'), true, '应识别完全印尼语的英语(美国)标签', ); const option = new FakeElement({ text: '\u200eEnglish\u2069' }); const optionRoot = { $$: async () => [option] }; assert.strictEqual(await _private.findOptionByText(optionRoot, 'English'), option, '应忽略双向文本控制字符'); const inlineInput = new FakeElement({ box: { x: 10, y: 10, width: 100, height: 20 } }); const inlineOption = new FakeElement({ text: 'English', box: { x: 10, y: 40, width: 100, height: 20 } }); const inlinePage = { $$: async selector => { if (selector === '[role="dialog"]') return []; if (selector === 'input') return [inlineInput]; if (selector === '[role="option"]') return [inlineOption]; return []; }, }; assert.strictEqual(await _private.findVisibleEditorRoot(inlinePage), inlinePage, '应兼容页面内嵌编辑器'); for (const saveButton of [ new FakeElement({ text: 'Save', aria: 'Save your language preference' }), new FakeElement({ text: '保存', aria: '保存您的语言偏好设置' }), new FakeElement({ text: 'Continue', attrs: { 'data-mdc-dialog-action': 'confirm' } }), ]) { const cancel = new FakeElement({ text: 'Cancel', aria: 'Cancel language change' }); const dialog = { $$: async () => [cancel, saveButton] }; assert.strictEqual(await _private.findSaveButton(dialog), saveButton, '应按按钮语义识别保存动作'); } const expectedPrimaryPage = new NavigationProbePage('expected_primary'); const expectedConfirmation = await _private.confirmEnglishUnitedStates(expectedPrimaryPage, () => {}); assert.strictEqual(expectedConfirmation.confirmed, true, '本次主 frame primary refresh 应继续并完成最终确认'); assert.strictEqual(expectedPrimaryPage.listenerCount('framenavigated'), 0, '成功恢复后应解绑监听'); const expectedAbortPage = new NavigationProbePage('expected_abort'); const expectedAbortResult = await _private.navigateLanguagePageSafely(expectedAbortPage); assert.strictEqual(expectedAbortResult.primaryRefresh, true, '本次主 frame primary 导航导致的 ERR_ABORTED 应可恢复'); assert.strictEqual(expectedAbortPage.listenerCount('framenavigated'), 0); const otherUrlPage = new NavigationProbePage('other_url'); await assert.rejects( () => _private.navigateLanguagePageSafely(otherUrlPage, null, { primaryRefreshTimeoutMs: 40 }), /accounts\.google\.com\/v3\/signin/, '被其他 URL 中断必须原样失败', ); assert.strictEqual(otherUrlPage.listenerCount('framenavigated'), 0); for (const [mode, errorPattern] of [ ['timeout', /Timeout 45000ms exceeded/], ['proxy', /ERR_TUNNEL_CONNECTION_FAILED/], ['page_closed', /has been closed/], ['old_primary', /ERR_ABORTED/], ['subframe_primary', /ERR_ABORTED/], ['resolved_login', /语言页导航到了意外地址/], ['resolved_chrome_error', /语言页导航到了意外地址/], ]) { const failurePage = new NavigationProbePage(mode); await assert.rejects( () => _private.navigateLanguagePageSafely(failurePage, null, { primaryRefreshTimeoutMs: 40 }), errorPattern, `${mode} 不得被当成可恢复的 primary refresh`, ); assert.strictEqual(failurePage.listenerCount('framenavigated'), 0, `${mode} 后应解绑监听`); } const page = new FakeLanguagePage(2200); const logs = []; const startedAt = Date.now(); const [ok, message] = await changeLanguageFromPage(page, line => logs.push(line)); const elapsed = Date.now() - startedAt; assert.strictEqual(ok, true, message); assert(elapsed >= 2000, '测试应覆盖点击两秒后编辑器仍未出现的场景'); assert.strictEqual(page.usSelected, true); assert.strictEqual(page.currentUrl, 'https://myaccount.google.com/language?refresh_reason=primary'); assert.strictEqual(page.listenerCount('framenavigated'), 0, '完成后应解绑导航监听'); assert(logs.some(line => line.includes('语言已成功更改'))); const cancelLastPage = new FakeLanguagePage(0, 'cancel_last'); const [cancelLastOk, cancelLastMessage] = await changeLanguageFromPage(cancelLastPage, () => {}); assert.strictEqual(cancelLastOk, false, '最后一个按钮为取消时不得当作保存'); assert(cancelLastMessage.includes('保存按钮')); assert.strictEqual(cancelLastPage.cancelClicked, false, '不得点击被明确识别为取消的按钮'); const notAppliedPage = new FakeLanguagePage(0, 'close_without_apply'); const [notAppliedOk, notAppliedMessage] = await changeLanguageFromPage(notAppliedPage, () => {}); assert.strictEqual(notAppliedOk, false, '弹窗关闭但语言未应用时必须失败'); assert(notAppliedMessage.includes('复查语言未变为')); assert(notAppliedPage.gotoCount >= 3, '未生效时应进行两次重新加载确认'); console.log('language_service.test.js: PASS'); })().catch(error => { console.error(error && error.stack ? error.stack : error); process.exitCode = 1; });