Files
go_bt/tests/language_service.test.js
T

376 lines
16 KiB
JavaScript

'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;
if (this.saveBehavior === 'auto_apply') {
this.mode = 'saved';
this.currentTitle = 'Language';
this.pencilButton.aria = 'Edit language: English (United States)';
this.emitMainNavigation('https://myaccount.google.com/language?refresh_reason=primary');
}
},
});
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: 'Uložiť', aria: 'Uložiť výber jazyka' }),
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 hindiCancel = new FakeElement({ text: 'रद्द करें', aria: 'भाषा बदलना रद्द करें' });
const hindiSave = new FakeElement({ text: 'चुनें', aria: 'भाषा चुनें' });
assert.strictEqual(
await _private.findSaveButton({ $$: async () => [hindiCancel, hindiSave] }),
hindiSave,
'Hindi 页面必须排除“रद्द करें”并识别“चुनें”为确认动作',
);
const slovakCancel = new FakeElement({ text: 'Zrušiť', aria: 'Zrušiť zmenu jazyka' });
const slovakSave = new FakeElement({ text: 'Uložiť', aria: 'Uložiť výber jazyka' });
assert.strictEqual(
await _private.findSaveButton({ $$: async () => [slovakCancel, slovakSave] }),
slovakSave,
'Slovak 页面必须排除“Zrušiť”并识别“Uložiť”为保存动作',
);
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 autoApplyPage = new FakeLanguagePage(0, 'auto_apply');
const [autoApplyOk, autoApplyMessage] = await changeLanguageFromPage(autoApplyPage, () => {});
assert.strictEqual(autoApplyOk, true, autoApplyMessage);
assert.strictEqual(autoApplyPage.usSelected, true, '自动保存版本也应先选择 United States');
assert.strictEqual(autoApplyPage.listenerCount('framenavigated'), 0, '自动保存完成后应解绑导航监听');
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;
});