From ef52ff806b9251458996d0282fb6aaf9d3ff4d11 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 04:31:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20meiguodizhi=20?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=B4=A6=E5=8D=95=E5=9C=B0=E5=9D=80=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B7=B3=E8=BF=87=20Google?= =?UTF-8?q?=20=E5=9C=B0=E5=9D=80=E6=8E=A8=E8=8D=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 1 + background/steps/fill-plus-checkout.js | 113 ++++++++++++- content/plus-checkout.js | 145 +++++++++++++++- docs/使用教程.md | 157 +++++++++++++++++- tests/plus-checkout-address-input.test.js | 54 ++++++ ...us-checkout-billing-tab-resolution.test.js | 137 ++++++++++++++- 6 files changed, 593 insertions(+), 14 deletions(-) diff --git a/background.js b/background.js index da53818..3ec9e4a 100644 --- a/background.js +++ b/background.js @@ -6924,6 +6924,7 @@ const plusCheckoutBillingExecutor = self.MultiPageBackgroundPlusCheckoutBilling? chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + fetch: typeof fetch === 'function' ? fetch.bind(globalThis) : null, generateRandomName, getAddressSeedForCountry: self.MultiPageAddressSources?.getAddressSeedForCountry, getTabId, diff --git a/background/steps/fill-plus-checkout.js b/background/steps/fill-plus-checkout.js index 7e58f65..cec29ba 100644 --- a/background/steps/fill-plus-checkout.js +++ b/background/steps/fill-plus-checkout.js @@ -5,6 +5,13 @@ const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i; const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500; + const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz'; + const MEIGUODIZHI_PATH_BY_COUNTRY = { + AU: '/au-address', + DE: '/de-address', + FR: '/fr-address', + US: '/', + }; function createPlusCheckoutBillingExecutor(deps = {}) { const { @@ -12,6 +19,7 @@ chrome, completeStepFromBackground, ensureContentScriptReadyOnTabUntilStopped, + fetch: fetchImpl = null, generateRandomName, getAddressSeedForCountry, getTabId, @@ -26,6 +34,97 @@ return PLUS_CHECKOUT_URL_PATTERN.test(String(url || '')); } + function normalizeText(value = '') { + return String(value || '').replace(/\s+/g, ' ').trim(); + } + + function hasCompleteAddressFallback(seed) { + const fallback = seed?.fallback || {}; + return Boolean( + normalizeText(fallback.address1) + && normalizeText(fallback.city) + && normalizeText(fallback.postalCode) + ); + } + + function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) { + const address1 = normalizeText(apiAddress?.Address); + const city = normalizeText(apiAddress?.City); + const region = normalizeText(apiAddress?.State || apiAddress?.State_Full); + const postalCode = normalizeText(apiAddress?.Zip_Code); + if (!address1 || !city || !postalCode) { + return null; + } + return { + ...(fallbackSeed || {}), + countryCode, + query: [address1, city].filter(Boolean).join(', '), + source: 'meiguodizhi', + skipAutocomplete: true, + fallback: { + ...(fallbackSeed?.fallback || {}), + address1, + city, + region, + postalCode, + }, + }; + } + + async function fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed) { + if (typeof fetchImpl !== 'function') { + return null; + } + const path = MEIGUODIZHI_PATH_BY_COUNTRY[countryCode] || MEIGUODIZHI_PATH_BY_COUNTRY.DE; + const city = normalizeText(fallbackSeed?.fallback?.city || fallbackSeed?.query || ''); + const response = await fetchImpl(MEIGUODIZHI_ADDRESS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + city, + path, + method: 'refresh', + }), + }); + if (!response?.ok) { + throw new Error(`HTTP ${response?.status || 0}`); + } + const data = await response.json(); + if (data?.status !== 'ok') { + throw new Error(data?.message || data?.status || 'unknown response'); + } + return buildDirectAddressSeed(countryCode, data.address || {}, fallbackSeed); + } + + async function resolveBillingAddressSeed(state = {}, countryOverride = '') { + const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE'); + const fallbackSeed = getAddressSeedForCountry(requestedCountry, { + fallbackCountry: 'DE', + }); + if (!fallbackSeed) { + throw new Error('步骤 7:未找到可用的本地账单地址种子。'); + } + + const countryCode = fallbackSeed.countryCode || 'DE'; + try { + const remoteSeed = await fetchMeiguodizhiAddressSeed(countryCode, fallbackSeed); + if (hasCompleteAddressFallback(remoteSeed)) { + await addLog( + `步骤 7:已从 meiguodizhi 接口获取账单地址(${remoteSeed.fallback.city} / ${remoteSeed.fallback.postalCode}),将跳过 Google 地址推荐。`, + 'info' + ); + return remoteSeed; + } + await addLog('步骤 7:meiguodizhi 接口返回的地址字段不完整,回退到本地地址种子。', 'warn'); + } catch (error) { + await addLog(`步骤 7:meiguodizhi 地址接口不可用,回退到本地地址种子:${error?.message || String(error || '')}`, 'warn'); + } + + return fallbackSeed; + } + async function getAlivePlusCheckoutTabId(tabId) { if (!Number.isInteger(tabId) || tabId <= 0) { return null; @@ -241,6 +340,7 @@ return { frameId: picked.frame.frameId, frameUrl: picked.frame.url || '', + countryText: picked.result?.countryText || '', ready: picked.frame.ready !== false, inspections, }; @@ -312,12 +412,6 @@ const randomName = generateRandomName(); const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' '); - const addressSeed = getAddressSeedForCountry(state.plusCheckoutCountry || 'DE', { - fallbackCountry: 'DE', - }); - if (!addressSeed) { - throw new Error('步骤 7:未找到可用的本地账单地址种子。'); - } await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info'); const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, { @@ -337,10 +431,15 @@ await addLog(`步骤 7:账单地址位于 checkout iframe(frameId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info'); } + const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText); + if (!addressSeed) { + throw new Error('步骤 7:未找到可用的本地账单地址种子。'); + } + await addLog(`步骤 7:正在填写账单地址(${addressSeed.countryCode} / ${addressSeed.query})...`, 'info'); const autocompleteFrame = await resolveOptionalFrameByUrl(tabId, isAutocompleteFrameUrl); let result = null; - if (autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { + if (!addressSeed.skipAutocomplete && autocompleteFrame?.frame && autocompleteFrame.frame.frameId !== billingFrame.frameId) { if (!autocompleteFrame.ready) { throw new Error('步骤 7:发现 Google 地址推荐 iframe,但无法注入账单脚本。请提供该 iframe 的控制台结构。'); } diff --git a/content/plus-checkout.js b/content/plus-checkout.js index 40bfcc4..1beca2d 100644 --- a/content/plus-checkout.js +++ b/content/plus-checkout.js @@ -651,6 +651,140 @@ async function fillAddressQuery(seed = {}) { }; } +function getRegionCandidates(value) { + const raw = normalizeText(value); + if (!raw) return []; + const aliases = { + act: 'Australian Capital Territory', + nsw: 'New South Wales', + nt: 'Northern Territory', + qld: 'Queensland', + sa: 'South Australia', + tas: 'Tasmania', + vic: 'Victoria', + wa: 'Western Australia', + }; + const compact = raw.toLowerCase().replace(/[^a-z0-9]/g, ''); + const candidates = [raw]; + if (aliases[compact]) { + candidates.push(aliases[compact]); + } + for (const [abbr, name] of Object.entries(aliases)) { + const compactName = name.toLowerCase().replace(/[^a-z0-9]/g, ''); + if (compact === compactName) { + candidates.push(abbr.toUpperCase()); + } + } + return Array.from(new Set(candidates.filter(Boolean))); +} + +function matchesRegionOption(text, desiredValue) { + const normalizedText = normalizeText(text).toLowerCase(); + const compactText = normalizedText.replace(/[^a-z0-9]/g, ''); + if (!compactText) return false; + return getRegionCandidates(desiredValue).some((candidate) => { + const normalizedCandidate = normalizeText(candidate).toLowerCase(); + const compactCandidate = normalizedCandidate.replace(/[^a-z0-9]/g, ''); + if (!compactCandidate) return false; + return normalizedText === normalizedCandidate + || compactText === compactCandidate + || (compactCandidate.length > 3 && compactText.includes(compactCandidate)); + }); +} + +function findRegionDropdown() { + const controls = getVisibleControls('select, button, [role="button"], [role="combobox"], [aria-haspopup="listbox"]'); + return controls.find((control) => { + if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false; + const text = getFieldText(control); + if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false; + return /state|province|county/i.test(text) + || /(?:^|\s)region(?:\s|$)/i.test(text) + || /\u5dde|\u7701/.test(text); + }) || null; +} + +function getRegionDropdownValue(control) { + if (!control) return ''; + if (String(control.tagName || '').toUpperCase() === 'SELECT') { + const selected = control.selectedOptions?.[0]; + return normalizeText(selected?.textContent || control.value || ''); + } + return normalizeText( + control.getAttribute?.('aria-valuetext') + || control.getAttribute?.('aria-label') + || control.getAttribute?.('data-value') + || control.textContent + || '' + ); +} + +function getVisibleRegionOptions() { + const selectors = [ + '[role="listbox"] [role="option"]', + '[role="option"]', + 'li', + ]; + const seen = new Set(); + const options = []; + for (const selector of selectors) { + for (const option of Array.from(document.querySelectorAll(selector))) { + if (!isVisibleElement(option)) continue; + const text = normalizeText(getActionText(option) || option.textContent || ''); + if (!text || seen.has(text)) continue; + seen.add(text); + options.push(option); + } + } + return options; +} + +async function selectRegionDropdown(regionDropdown, value) { + if (!regionDropdown || !value) return false; + if (matchesRegionOption(getRegionDropdownValue(regionDropdown), value)) { + return false; + } + + if (String(regionDropdown.tagName || '').toUpperCase() === 'SELECT') { + const option = Array.from(regionDropdown.options || []).find((item) => ( + matchesRegionOption(item.textContent || '', value) + || matchesRegionOption(item.value || '', value) + )); + if (!option) { + throw new Error(`Plus Checkout: state dropdown option "${value}" was not found.`); + } + regionDropdown.value = option.value; + option.selected = true; + regionDropdown.dispatchEvent(new Event('input', { bubbles: true })); + regionDropdown.dispatchEvent(new Event('change', { bubbles: true })); + return true; + } + + simulateClick(regionDropdown); + await sleep(250); + const startedAt = Date.now(); + let option = null; + while (Date.now() - startedAt < 2500) { + throwIfStopped(); + option = getVisibleRegionOptions().find((item) => ( + matchesRegionOption(getActionText(item) || item.textContent || '', value) + )); + if (option) break; + await sleep(100); + } + if (!option) { + const visibleOptions = getVisibleRegionOptions() + .map((item) => normalizeText(getActionText(item) || item.textContent || '')) + .filter(Boolean) + .slice(0, 12) + .join(' | '); + throw new Error(`Plus Checkout: state dropdown option "${value}" was not found. Visible options: ${visibleOptions || 'none'}.`); + } + simulateClick(option); + await sleep(500); + return true; +} + function getStructuredAddressFields() { const address1 = findInputByFieldText([ /address\s*(?:line)?\s*1|street/i, @@ -697,7 +831,7 @@ async function ensureStructuredAddress(seed) { fillIfEmpty(fields.address1, fallback.address1); fillIfEmpty(fields.city, fallback.city); - fillIfEmpty(fields.region, fallback.region); + await selectRegionDropdown(findRegionDropdown(), fallback.region); fillIfEmpty(fields.postalCode, fallback.postalCode); await sleep(500); @@ -713,7 +847,7 @@ async function ensureStructuredAddress(seed) { return { address1: latest.address1?.value || '', city: latest.city?.value || '', - region: latest.region?.value || '', + region: getRegionDropdownValue(findRegionDropdown()) || latest.region?.value || '', postalCode: latest.postalCode?.value || '', }; } @@ -759,7 +893,10 @@ async function fillPlusBillingAddress(payload = {}) { postalCode: '10117', }, }; - const selected = await selectAddressSuggestion(seed); + let selected = { selectedText: '' }; + if (!seed.skipAutocomplete) { + selected = await selectAddressSuggestion(seed); + } const structuredAddress = await ensureStructuredAddress(seed); return { @@ -828,7 +965,7 @@ function inspectPlusCheckoutState() { addressFieldValues: { address1: structuredAddress.address1?.value || '', city: structuredAddress.city?.value || '', - region: structuredAddress.region?.value || '', + region: getRegionDropdownValue(findRegionDropdown()) || structuredAddress.region?.value || '', postalCode: structuredAddress.postalCode?.value || '', }, }; diff --git a/docs/使用教程.md b/docs/使用教程.md index c58dda8..2b2e1c1 100644 --- a/docs/使用教程.md +++ b/docs/使用教程.md @@ -171,7 +171,162 @@ 常见情况是上传身份证件。 `PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。 -### 第六部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` +### 第六部分:0元试用 ChatGPT Plus 教程 + +本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。 + +#### 准备工作 + +1. 已有一个登录状态的 ChatGPT 账户。 +2. 一个可用的 PayPal 账户(参考第五部分进行注册和绑卡)。 +3. 能够接收生成的账单地址的真实地址或虚拟地址。 +4. Chrome 浏览器(推荐使用地址补全功能)。 + +#### 执行步骤 + +1. **进入 ChatGPT 并打开开发者工具** + 在已登录 ChatGPT 的页面上,按 `F12` 打开浏览器开发者工具。 + 点击 `Console`(控制台)标签进入命令行界面。 + +2. **允许粘贴脚本** + 在控制台输入 `allow pasting` 并回车。 + 浏览器将允许你粘贴多行脚本代码。 + +3. **粘贴并执行脚本** + 复制下方脚本代码,粘贴到控制台,然后回车执行。 + +```javascript +(async function(){ + try { + const t = await (await fetch("/api/auth/session")).json(); + if(!t.accessToken){ + alert("请先登录 ChatGPT!"); + return; + } + + // 核心1:强制使用触发 PayPal 的欧洲区参数 (DE 德国 / EUR 欧元) + const payload = { + entry_point: "all_plans_pricing_modal", + plan_name: "chatgptplusplan", // Plus 的套餐名 + billing_details: { + country: "DE", // 必须是 DE 或 FR 才能在后续页面使用 PayPal + currency: "EUR" + }, + checkout_ui_mode: "custom", + promo_campaign: { + promo_campaign_id: "plus-1-month-free", // Plus 对应优惠码 + is_coupon_from_query_param: false + } + }; + + const response = await fetch("https://chatgpt.com/backend-api/payments/checkout", { + method: "POST", + headers: { + "Authorization": "Bearer " + t.accessToken, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if(data.checkout_session_id) { + // 核心2:拼接 Plus 的专属支付短链 + // 如果 openai_ie 报错,可以直接把 /openai_ie 删掉,变成 "https://chatgpt.com/checkout/" + data.checkout_session_id + const shortLink = "https://chatgpt.com/checkout/openai_ie/" + data.checkout_session_id; + + // 弹窗让你复制这个带有 PayPal 的短链 + prompt("提取成功!这是你的 Plus 支付短链(复制保留):", shortLink); + + // 自动跳转到该短链 + window.location.href = shortLink; + } else { + console.error(data); + alert("提取失败:" + (data.detail || JSON.stringify(data))); + } + } catch(e) { + alert("发生异常:" + e); + } +})(); +``` + +4. **复制支付短链** + 脚本执行后会弹出一个对话框,显示你的 Plus 支付短链。 + 点击"确定"按钮,页面会自动跳转到支付页面。 + (可以复制这个链接备用,以防需要重新进入。) + +5. **选择 PayPal 支付** + 页面加载完成后,你会看到 ChatGPT Plus 的 checkout 页面,标题通常为"开始免费试用 Plus"。 + 在左侧付款方式中选择 `PayPal`。 + +6. **填写账单信息 - 选择国家** + 页面右侧会显示账单地址表单。 + "国家或地区" 字段会根据你当前的地址预填(通常是德国或法国)。 + 如果页面显示的不是你期望的国家,可以点击下拉框更改(建议保持德国或法国,以确保支付流程顺利)。 + +7. **填写账单信息 - 输入完整名字** + 在 "全名" 字段中输入完整的英文名字(例如 `John Smith`)。 + +8. **生成和填写地址** + 在 "地址第 1 行" 字段中开始输入。 + 输入城市名、州名或街道名(例如输入 `Berlin` 或 `New York`)。 + 页面会弹出 Google 地址推荐列表。 + 从推荐列表中选择一个地址项(建议选择第二项)。 + 页面会自动回填"城市"、"州"、"邮编"等字段。 + +9. **使用虚拟地址生成工具(可选)** + 如果地址推荐没有出现或推荐的地址不满足需求,可以使用 [https://www.meiguodizhi.com/](https://www.meiguodizhi.com/) 生成虚拟地址。 + 在该网站输入城市名或随机字母获取推荐地址,复制完整地址后粘贴到表单对应字段。 + +10. **完成地址填写并点击订阅** + 确认所有必填字段已填完(全名、地址、城市、州、邮编)。 + 在右侧点击 "订阅" 按钮。 + 页面会跳转到 PayPal 登录界面。 + +11. **PayPal 登录** + 在 PayPal 登录页输入 PayPal 账户邮箱。 + 输入 PayPal 账户密码。 + 点击 "登录" 按钮。 + +12. **处理浏览器通行密钥提示(如果出现)** + 如果出现 "要在无痕模式以外保存此通行密钥吗?" 的弹窗,点击 "取消"。 + +13. **关闭 PayPal 引导弹窗(如果出现)** + 如果出现 "下次登录更快捷" 或其他 PayPal 通行密钥引导弹窗,点击右上角的关闭图标。 + +14. **同意并继续** + 页面显示 "只需一次设置,结账更快捷。" 以及向 `OpenAI Ireland Limited` 付款的摘要。 + 点击 "同意并继续" 按钮。 + 等待页面跳转并加载完成。 + +15. **订阅成功** + 页面会回跳到 ChatGPT 或 OpenAI 的订阅确认页面。 + 此时 Plus 的0元试用订阅已成功完成。 + 你现在可以使用 ChatGPT Plus 的所有功能,试用期结束后会自动按月续订。 + +#### 常见问题处理 + +- **脚本执行报错 "请先登录 ChatGPT!"** + 请确认你当前已登录 ChatGPT 账户。退出重新登录后再尝试。 + +- **脚本执行返回错误信息** + 检查网络连接是否正常。如果多次尝试仍然失败,可能是当前账户不符合0元试用条件,请稍后再试。 + +- **/openai_ie 部分出现404错误** + 如果短链中的 `/openai_ie/` 无法访问,手动修改短链为: + `https://chatgpt.com/checkout/{checkout_session_id}` (删除 `/openai_ie/`) + 然后在浏览器地址栏中重新访问。 + +- **支付页面没有显示 PayPal 选项** + 这可能是因为脚本中的 `country` 参数不是 `DE` 或 `FR`。重新运行脚本,确保国家参数为德国 (DE) 或法国 (FR)。 + +- **地址推荐没有出现** + 稍等 1-2 秒,输入框下方应该会出现地址推荐列表。如果仍未出现,尝试清空后重新输入,或直接使用虚拟地址生成网站的地址。 + +- **PayPal 登录后页面无法继续跳转** + 稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。 + +### 第七部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` #### 第一步:添加扩展脚本 diff --git a/tests/plus-checkout-address-input.test.js b/tests/plus-checkout-address-input.test.js index 99bf2d6..fd95e91 100644 --- a/tests/plus-checkout-address-input.test.js +++ b/tests/plus-checkout-address-input.test.js @@ -199,3 +199,57 @@ return { isPayPalPaymentMethodActive }; paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : '')); assert.equal(api.isPayPalPaymentMethodActive(), true); }); + +test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => { + const bundle = [ + 'function throwIfStopped() {}', + 'function sleep() { return Promise.resolve(); }', + extractFunction('isVisibleElement'), + extractFunction('normalizeText'), + extractFunction('getActionText'), + extractFunction('getRegionCandidates'), + extractFunction('matchesRegionOption'), + extractFunction('getRegionDropdownValue'), + extractFunction('getVisibleRegionOptions'), + extractFunction('selectRegionDropdown'), + ].join('\n'); + + const state = { opened: false }; + const clicks = []; + const stateDropdown = createElement({ + tagName: 'DIV', + text: 'State New South Wales', + attrs: { + role: 'combobox', + 'aria-haspopup': 'listbox', + }, + }); + const options = [ + createElement({ tagName: 'DIV', text: 'New South Wales', attrs: { role: 'option' } }), + createElement({ tagName: 'DIV', text: 'Western Australia', attrs: { role: 'option' } }), + ]; + const documentMock = { + querySelectorAll: (selector) => { + if (!state.opened) return []; + if (selector === '[role="listbox"] [role="option"]' || selector === '[role="option"]') return options; + if (selector === 'li') return []; + return []; + }, + }; + const windowMock = { + getComputedStyle: () => ({ display: 'block', visibility: 'visible' }), + }; + + const api = new Function('window', 'document', 'Event', 'clicks', 'stateDropdown', 'state', ` +function simulateClick(el) { + clicks.push(el); + if (el === stateDropdown) state.opened = true; +} +${bundle} +return { selectRegionDropdown }; +`)(windowMock, documentMock, Event, clicks, stateDropdown, state); + + await api.selectRegionDropdown(stateDropdown, 'Western Australia'); + + assert.deepEqual(clicks, [stateDropdown, options[1]]); +}); diff --git a/tests/plus-checkout-billing-tab-resolution.test.js b/tests/plus-checkout-billing-tab-resolution.test.js index 679e055..350d6f5 100644 --- a/tests/plus-checkout-billing-tab-resolution.test.js +++ b/tests/plus-checkout-billing-tab-resolution.test.js @@ -22,6 +22,20 @@ function createAddressSeed() { }; } +function createAuAddressSeed() { + return { + countryCode: 'AU', + query: 'Sydney NSW', + suggestionIndex: 1, + fallback: { + address1: 'George Street', + city: 'Sydney', + region: 'New South Wales', + postalCode: '2000', + }, + }; +} + function createSuccessfulBillingResult() { return { countryText: 'Germany', @@ -33,7 +47,13 @@ function createSuccessfulBillingResult() { }; } -function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { +function createExecutorHarness({ + frames, + stateByFrame, + readyByFrame = {}, + fetchImpl = null, + getAddressSeedForCountry = () => createAddressSeed(), +}) { const api = loadPlusCheckoutBillingModule(); const events = { completed: [], @@ -96,8 +116,9 @@ function createExecutorHarness({ frames, stateByFrame, readyByFrame = {} }) { }, completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }), ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }), + fetch: fetchImpl, generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }), - getAddressSeedForCountry: () => createAddressSeed(), + getAddressSeedForCountry, getTabId: async () => null, isTabAlive: async () => false, setState: async (updates) => events.states.push(updates), @@ -221,6 +242,118 @@ test('Plus checkout billing uses the autocomplete iframe for address suggestions assert.equal(events.completed[0].step, 7); }); +test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => { + const fetchRequests = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + { frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true }, + 9: { hasPayPal: false, paypalCandidates: [] }, + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: 'Rosa-Luxemburg-Strasse 40', + City: 'Berlin', + State: 'Berlin', + Zip_Code: '69081', + }, + }), + }; + }, + }); + + await executor.executePlusCheckoutBilling({}); + + const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'); + const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'); + const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS'); + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(fillQueryMessage, undefined); + assert.equal(suggestionMessage, undefined); + assert.equal(ensureAddressMessage, undefined); + assert.equal(combinedFillMessage.frameId, 8); + assert.equal(combinedFillMessage.message.payload.addressSeed.skipAutocomplete, true); + assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, 'Rosa-Luxemburg-Strasse 40'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.city, 'Berlin'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.postalCode, '69081'); + assert.equal(fetchRequests.length, 1); + assert.equal(fetchRequests[0].url, 'https://www.meiguodizhi.com/api/v1/dz'); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Berlin', + path: '/de-address', + method: 'refresh', + }); + assert.equal(events.completed[0].step, 7); +}); + +test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => { + const requestedCountries = []; + const fetchRequests = []; + const { events, executor } = createExecutorHarness({ + frames: [ + { frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }, + { frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' }, + { frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' }, + ], + stateByFrame: { + 0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true }, + 7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] }, + 8: { + hasPayPal: false, + paypalCandidates: [], + billingFieldsVisible: true, + countryText: 'Australia', + }, + }, + getAddressSeedForCountry: (countryValue) => { + requestedCountries.push(countryValue); + return /australia|au/i.test(String(countryValue || '')) ? createAuAddressSeed() : createAddressSeed(); + }, + fetchImpl: async (url, init) => { + fetchRequests.push({ url, init }); + return { + ok: true, + status: 200, + json: async () => ({ + status: 'ok', + address: { + Address: '98 Ocean Street', + City: 'Sydney South', + State: 'New South Wales', + Zip_Code: '2000', + }, + }), + }; + }, + }); + + await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' }); + + const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'); + assert.equal(requestedCountries[0], 'Australia'); + assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU'); + assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales'); + assert.deepEqual(JSON.parse(fetchRequests[0].init.body), { + city: 'Sydney', + path: '/au-address', + method: 'refresh', + }); +}); + test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => { const { executor } = createExecutorHarness({ frames: [