fix: support localized add-phone country matching
- 合并 PR #155 的核心改动:add-phone 国家选择改为结合 option value 与区域显示名匹配 HeroSMS 英文国家名,兼容中文页面。 - 本地补充修复:同步登记新增的国家匹配回归测试到项目结构文档,并把 PR 分支吸收最新 dev。 - 影响范围:content/phone-auth.js、tests/phone-auth-country-match.test.js、项目文件结构说明.md
This commit is contained in:
+74
-8
@@ -50,6 +50,69 @@
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeCountryOptionValue(value) {
|
||||||
|
return String(value || '').trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionDisplayName(regionCode, locale) {
|
||||||
|
const normalizedRegionCode = normalizeCountryOptionValue(regionCode);
|
||||||
|
const normalizedLocale = String(locale || '').trim();
|
||||||
|
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return String(
|
||||||
|
new Intl.DisplayNames([normalizedLocale], { type: 'region' }).of(normalizedRegionCode) || ''
|
||||||
|
).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountryOptionMatchLabels(option) {
|
||||||
|
const labels = new Set();
|
||||||
|
const pushLabel = (value) => {
|
||||||
|
const label = String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (label) {
|
||||||
|
labels.add(label);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pushLabel(getOptionLabel(option));
|
||||||
|
|
||||||
|
const regionCode = normalizeCountryOptionValue(option?.value);
|
||||||
|
if (/^[A-Z]{2}$/.test(regionCode)) {
|
||||||
|
pushLabel(regionCode);
|
||||||
|
pushLabel(getRegionDisplayName(regionCode, 'en'));
|
||||||
|
|
||||||
|
const pageLocale = String(
|
||||||
|
document?.documentElement?.lang
|
||||||
|
|| document?.documentElement?.getAttribute?.('lang')
|
||||||
|
|| self?.navigator?.language
|
||||||
|
|| ''
|
||||||
|
).trim();
|
||||||
|
if (pageLocale && !/^en(?:[-_]|$)/i.test(pageLocale)) {
|
||||||
|
pushLabel(getRegionDisplayName(regionCode, pageLocale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameCountryOption(left, right) {
|
||||||
|
if (!left || !right) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftValue = normalizeCountryOptionValue(left.value);
|
||||||
|
const rightValue = normalizeCountryOptionValue(right.value);
|
||||||
|
if (leftValue && rightValue) {
|
||||||
|
return leftValue === rightValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeCountryLabel(getOptionLabel(left)) === normalizeCountryLabel(getOptionLabel(right));
|
||||||
|
}
|
||||||
|
|
||||||
function extractDialCodeFromText(value) {
|
function extractDialCodeFromText(value) {
|
||||||
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
|
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
|
||||||
return String(match?.[1] || match?.[2] || '').trim();
|
return String(match?.[1] || match?.[2] || '').trim();
|
||||||
@@ -161,10 +224,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = Array.from(select.options);
|
const options = Array.from(select.options);
|
||||||
return options.find((option) => normalizeCountryLabel(getOptionLabel(option)) === normalizedTarget)
|
return options.find((option) => (
|
||||||
|
getCountryOptionMatchLabels(option).some((label) => normalizeCountryLabel(label) === normalizedTarget)
|
||||||
|
))
|
||||||
|| options.find((option) => {
|
|| options.find((option) => {
|
||||||
const optionLabel = normalizeCountryLabel(getOptionLabel(option));
|
const normalizedLabels = getCountryOptionMatchLabels(option)
|
||||||
return optionLabel && (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel));
|
.map((label) => normalizeCountryLabel(label))
|
||||||
|
.filter(Boolean);
|
||||||
|
return normalizedLabels.some((optionLabel) => (
|
||||||
|
optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel)
|
||||||
|
));
|
||||||
})
|
})
|
||||||
|| null;
|
|| null;
|
||||||
}
|
}
|
||||||
@@ -181,7 +250,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectedOption = getSelectedCountryOption();
|
const selectedOption = getSelectedCountryOption();
|
||||||
if (selectedOption && normalizeCountryLabel(getOptionLabel(selectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption))) {
|
if (selectedOption && isSameCountryOption(selectedOption, targetOption)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,10 +259,7 @@
|
|||||||
await sleep(250);
|
await sleep(250);
|
||||||
|
|
||||||
const nextSelectedOption = getSelectedCountryOption();
|
const nextSelectedOption = getSelectedCountryOption();
|
||||||
return Boolean(
|
return Boolean(nextSelectedOption && isSameCountryOption(nextSelectedOption, targetOption));
|
||||||
nextSelectedOption
|
|
||||||
&& normalizeCountryLabel(getOptionLabel(nextSelectedOption)) === normalizeCountryLabel(getOptionLabel(targetOption))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAddPhoneSubmitButton() {
|
function getAddPhoneSubmitButton() {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const source = fs.readFileSync('content/phone-auth.js', 'utf8');
|
||||||
|
const globalScope = { navigator: { language: 'zh-CN' } };
|
||||||
|
const api = new Function('self', `${source}; return self.MultiPagePhoneAuth;`)(globalScope);
|
||||||
|
|
||||||
|
function createFakeAddPhoneDom() {
|
||||||
|
const selectEvents = [];
|
||||||
|
const hiddenInputEvents = [];
|
||||||
|
let submitClicked = false;
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'US', textContent: '美国' },
|
||||||
|
{ value: 'TH', textContent: '泰国' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const select = {
|
||||||
|
options,
|
||||||
|
selectedIndex: 0,
|
||||||
|
dispatchEvent(event) {
|
||||||
|
selectEvents.push(event?.type || '');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(select, 'value', {
|
||||||
|
get() {
|
||||||
|
return options[select.selectedIndex]?.value || '';
|
||||||
|
},
|
||||||
|
set(nextValue) {
|
||||||
|
const nextIndex = options.findIndex((option) => option.value === String(nextValue || ''));
|
||||||
|
if (nextIndex >= 0) {
|
||||||
|
select.selectedIndex = nextIndex;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const phoneInput = {
|
||||||
|
value: '',
|
||||||
|
type: 'tel',
|
||||||
|
dispatchEvent() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
closest() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hiddenPhoneInput = {
|
||||||
|
value: '',
|
||||||
|
dispatchEvent(event) {
|
||||||
|
hiddenInputEvents.push(event?.type || '');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectValueNode = {
|
||||||
|
get textContent() {
|
||||||
|
return select.value === 'TH' ? '泰国 (+66)' : '美国 (+1)';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const countryButton = {
|
||||||
|
querySelector(selector) {
|
||||||
|
return selector === '.react-aria-SelectValue' ? selectValueNode : null;
|
||||||
|
},
|
||||||
|
get textContent() {
|
||||||
|
return selectValueNode.textContent;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitButton = {
|
||||||
|
type: 'submit',
|
||||||
|
click() {
|
||||||
|
submitClicked = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = {
|
||||||
|
querySelector(selector) {
|
||||||
|
switch (selector) {
|
||||||
|
case 'select':
|
||||||
|
return select;
|
||||||
|
case 'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]':
|
||||||
|
return phoneInput;
|
||||||
|
case 'input[name="phoneNumber"]':
|
||||||
|
return hiddenPhoneInput;
|
||||||
|
case 'button[aria-haspopup="listbox"]':
|
||||||
|
return countryButton;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
if (selector === 'button[type="submit"], input[type="submit"]') {
|
||||||
|
return [submitButton];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const document = {
|
||||||
|
documentElement: {
|
||||||
|
lang: 'zh-CN',
|
||||||
|
getAttribute(name) {
|
||||||
|
return name === 'lang' ? 'zh-CN' : '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === 'form[action*="/add-phone" i]') {
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
document,
|
||||||
|
hiddenInputEvents,
|
||||||
|
hiddenPhoneInput,
|
||||||
|
phoneInput,
|
||||||
|
select,
|
||||||
|
selectEvents,
|
||||||
|
submitButton,
|
||||||
|
wasSubmitClicked: () => submitClicked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('phone auth matches english HeroSMS country labels against localized add-phone options', async () => {
|
||||||
|
const originalDocument = global.document;
|
||||||
|
const originalEvent = global.Event;
|
||||||
|
const originalLocation = global.location;
|
||||||
|
const OriginalDisplayNames = Intl.DisplayNames;
|
||||||
|
|
||||||
|
const dom = createFakeAddPhoneDom();
|
||||||
|
let phoneVerificationReady = false;
|
||||||
|
|
||||||
|
global.document = dom.document;
|
||||||
|
global.Event = class Event {
|
||||||
|
constructor(type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
global.location = { href: 'https://auth.openai.com/add-phone' };
|
||||||
|
Intl.DisplayNames = class DisplayNames {
|
||||||
|
of(regionCode) {
|
||||||
|
if (regionCode === 'TH') return 'Thailand';
|
||||||
|
if (regionCode === 'US') return 'United States';
|
||||||
|
return regionCode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const helpers = api.createPhoneAuthHelpers({
|
||||||
|
fillInput: (element, value) => {
|
||||||
|
element.value = value;
|
||||||
|
},
|
||||||
|
getActionText: () => '',
|
||||||
|
getPageTextSnapshot: () => '',
|
||||||
|
getVerificationErrorText: () => '',
|
||||||
|
humanPause: async () => {},
|
||||||
|
isActionEnabled: () => true,
|
||||||
|
isAddPhonePageReady: () => true,
|
||||||
|
isConsentReady: () => false,
|
||||||
|
isPhoneVerificationPageReady: () => phoneVerificationReady,
|
||||||
|
isVisibleElement: () => true,
|
||||||
|
simulateClick: (element) => {
|
||||||
|
element.click?.();
|
||||||
|
phoneVerificationReady = true;
|
||||||
|
global.location.href = 'https://auth.openai.com/phone-verification';
|
||||||
|
},
|
||||||
|
sleep: async () => {},
|
||||||
|
throwIfStopped: () => {},
|
||||||
|
waitForElement: async () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await helpers.submitPhoneNumber({
|
||||||
|
countryLabel: 'Thailand',
|
||||||
|
phoneNumber: '66959916439',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(dom.select.value, 'TH');
|
||||||
|
assert.deepStrictEqual(dom.selectEvents, ['input', 'change']);
|
||||||
|
assert.equal(dom.phoneInput.value, '959916439');
|
||||||
|
assert.equal(dom.hiddenPhoneInput.value, '+66959916439');
|
||||||
|
assert.deepStrictEqual(dom.hiddenInputEvents, ['input', 'change']);
|
||||||
|
assert.equal(dom.wasSubmitClicked(), true);
|
||||||
|
assert.deepStrictEqual(result, {
|
||||||
|
phoneVerificationPage: true,
|
||||||
|
displayedPhone: '',
|
||||||
|
url: 'https://auth.openai.com/phone-verification',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
global.document = originalDocument;
|
||||||
|
global.Event = originalEvent;
|
||||||
|
global.location = originalLocation;
|
||||||
|
Intl.DisplayNames = OriginalDisplayNames;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -195,6 +195,7 @@
|
|||||||
- `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
|
- `tests/background-step-registry.test.js`:测试后台步骤注册表和共享步骤定义已接入。
|
||||||
- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成、等待标签稳定完成,以及等待过程中的 Stop 中断行为。
|
- `tests/background-tab-runtime-module.test.js`:测试标签运行时模块已接入且导出工厂,并覆盖等待标签完成、等待标签稳定完成,以及等待过程中的 Stop 中断行为。
|
||||||
- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
|
- `tests/background-verification-flow-module.test.js`:测试验证码流程模块已接入且导出工厂。
|
||||||
|
- `tests/phone-auth-country-match.test.js`:测试认证页手机号国家选择在页面本地化时,仍能用 HeroSMS 的英文国家名匹配对应国家选项。
|
||||||
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
|
- `tests/phone-verification-flow.test.js`:测试手机号验证共享流程对 HeroSMS 的取号、复用、重发、换号与 Step 7 重开错误分支。
|
||||||
- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。
|
- `tests/plus-checkout-billing-tab-resolution.test.js`:测试 Plus 第 7 步可直接接管当前 checkout 标签页,并把 PayPal、账单地址、Google 地址推荐和订阅按钮操作路由到对应 Stripe iframe。
|
||||||
- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
|
- `tests/cloudflare-temp-email-provider.test.js`:测试 Cloudflare Temp Email provider 的轮询与目标邮箱选择逻辑。
|
||||||
|
|||||||
Reference in New Issue
Block a user