344 lines
14 KiB
JavaScript
344 lines
14 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; },
|
|
});
|
|
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;
|
|
});
|