From 227ebedda4966eca7a1458436b7ed709d060b0f2 Mon Sep 17 00:00:00 2001 From: QLHazyCoder <2825305047@qq.com> Date: Sun, 26 Apr 2026 08:51:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Plus=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=92=8C=E8=B4=A1=E7=8C=AE=E6=A8=A1=E5=BC=8F=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=AD=A5=E9=AA=A4=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E5=BB=B6=E8=BF=9F=E5=92=8C=E6=8F=90=E7=A4=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- background.js | 23 +- background/account-run-history.js | 4 + background/steps/create-plus-checkout.js | 4 +- sidepanel/sidepanel.css | 16 + sidepanel/sidepanel.js | 156 +++++++++- tests/auto-step-random-delay.test.js | 26 +- ...kground-account-run-history-module.test.js | 35 +++ tests/plus-checkout-create-wait.test.js | 13 +- ...sidepanel-auto-run-content-refresh.test.js | 23 ++ ...sidepanel-plus-contribution-prompt.test.js | 291 ++++++++++++++++++ 10 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 tests/sidepanel-plus-contribution-prompt.test.js diff --git a/background.js b/background.js index 8406c39..9d3e61e 100644 --- a/background.js +++ b/background.js @@ -5505,6 +5505,9 @@ const STEP_COMPLETION_SIGNAL_STEP_KEYS = new Set([ 'fill-profile', 'platform-verify', ]); +const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([ + ['plus-checkout-create', 20000], +]); function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { @@ -5549,6 +5552,14 @@ function doesStepUseCompletionSignal(step, state = {}) { return STEP_COMPLETION_SIGNAL_STEPS.has(step); } +function getAutoRunPreExecutionDelayMs(step, state = {}) { + const stepKey = getStepExecutionKeyForState(step, state); + if (stepKey) { + return AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY.get(stepKey) || 0; + } + return 0; +} + function notifyStepComplete(step, payload) { const waiter = stepWaiters.get(step); console.log(LOG_PREFIX, `[notifyStepComplete] step ${step}, hasWaiter=${Boolean(waiter)}`); @@ -5914,7 +5925,17 @@ async function executeStepAndWait(step, delayAfter = 2000) { await sleepWithStop(delaySeconds * 1000); } - const executionState = await getState(); + let executionState = await getState(); + const preExecutionDelayMs = getAutoRunPreExecutionDelayMs(step, executionState); + if (preExecutionDelayMs > 0) { + await addLog( + `自动运行:步骤 ${step} 执行前固定等待 ${Math.round(preExecutionDelayMs / 1000)} 秒,确保 Plus Checkout 创建前页面稳定。`, + 'info' + ); + await sleepWithStop(preExecutionDelayMs); + executionState = await getState(); + } + if (doesStepUseBackgroundCompletion(step, executionState)) { await addLog(`自动运行:步骤 ${step} 由后台流程负责收尾,执行函数返回后将直接进入下一步。`, 'info'); await executeStep(step); diff --git a/background/account-run-history.js b/background/account-run-history.js index 376f0cc..ca1f2dd 100644 --- a/background/account-run-history.js +++ b/background/account-run-history.js @@ -180,6 +180,8 @@ failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, autoRunContext: source === 'auto' ? autoRunContext : null, + plusModeEnabled: Boolean(record.plusModeEnabled), + contributionMode: Boolean(record.contributionMode), }; } @@ -242,6 +244,8 @@ failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null, source, autoRunContext, + plusModeEnabled: Boolean(state.plusModeEnabled), + contributionMode: Boolean(state.contributionMode), }; } diff --git a/background/steps/create-plus-checkout.js b/background/steps/create-plus-checkout.js index fb995ab..485aa94 100644 --- a/background/steps/create-plus-checkout.js +++ b/background/steps/create-plus-checkout.js @@ -4,7 +4,6 @@ const PLUS_CHECKOUT_SOURCE = 'plus-checkout'; const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/'; const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js']; - const PLUS_CHECKOUT_POST_CREATE_WAIT_MS = 20000; function createPlusCheckoutCreateExecutor(deps = {}) { const { @@ -65,8 +64,7 @@ plusCheckoutCurrency: result.currency || 'EUR', }); - await addLog('步骤 6:Plus Checkout 页面已就绪,固定等待 20 秒后继续下一步。', 'info'); - await sleepWithStop(PLUS_CHECKOUT_POST_CREATE_WAIT_MS); + await addLog('步骤 6:Plus Checkout 页面已就绪,准备继续下一步。', 'info'); await completeStepFromBackground(6, { plusCheckoutCountry: result.country || 'DE', diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 923983c..d45da4a 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -2419,6 +2419,22 @@ header { white-space: pre-line; } +.plus-contribution-prompt-copy { + display: block; + margin-bottom: 8px; +} + +.plus-contribution-prompt-image { + display: block; + width: min(220px, 100%); + max-height: 280px; + object-fit: contain; + margin: 12px auto 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-primary); +} + .modal-message a, .modal-alert a { color: var(--blue); diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 15a420a..65f3bf5 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -278,6 +278,7 @@ const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-pr const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-failures-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed'; const AUTO_RUN_PLUS_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-plus-risk-prompt-dismissed'; +const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger'; function getStepDefinitionsForMode(plusModeEnabled = false) { return (window.MultiPageStepDefinitions?.getSteps?.({ plusModeEnabled }) || []) @@ -299,6 +300,9 @@ function rebuildStepDefinitionState(plusModeEnabled = false) { const CONTRIBUTION_CONTENT_PROMPT_DISMISSED_VERSION_STORAGE_KEY = 'multipage-contribution-content-prompt-dismissed-version'; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3; const AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3; +const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5; +const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5; +const PLUS_CONTRIBUTION_DONATION_CREDIT = 20; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const ICLOUD_PROVIDER = 'icloud'; @@ -1040,6 +1044,147 @@ function shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) { && Math.floor(Number(totalRuns) || 0) > AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS; } +function normalizePlusContributionPromptNumber(value) { + const number = Math.floor(Number(value) || 0); + return Number.isFinite(number) ? number : 0; +} + +function normalizePlusContributionPromptLedger(value = {}) { + const source = value && typeof value === 'object' ? value : {}; + return { + promptBaseline: normalizePlusContributionPromptNumber(source.promptBaseline), + donationCredit: Math.max(0, normalizePlusContributionPromptNumber(source.donationCredit)), + }; +} + +function getPlusContributionPromptLedger() { + try { + return normalizePlusContributionPromptLedger( + JSON.parse(localStorage.getItem(PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY) || '{}') + ); + } catch { + return normalizePlusContributionPromptLedger(); + } +} + +function setPlusContributionPromptLedger(ledger) { + localStorage.setItem( + PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY, + JSON.stringify(normalizePlusContributionPromptLedger(ledger)) + ); +} + +function isSuccessfulPlusAccountRecord(record = {}) { + return record?.finalStatus === 'success' && Boolean(record.plusModeEnabled); +} + +function getPlusContributionPromptTotals(records = []) { + return (Array.isArray(records) ? records : []).reduce((totals, record) => { + if (!isSuccessfulPlusAccountRecord(record)) { + return totals; + } + if (record.contributionMode) { + totals.contributionSuccess += 1; + } else { + totals.plusSuccess += 1; + } + return totals; + }, { + plusSuccess: 0, + contributionSuccess: 0, + }); +} + +function getPlusContributionPromptProgress(records = [], ledger = getPlusContributionPromptLedger()) { + const totals = getPlusContributionPromptTotals(records); + const normalizedLedger = normalizePlusContributionPromptLedger(ledger); + const credit = (totals.contributionSuccess * PLUS_CONTRIBUTION_ACCOUNT_CREDIT) + + normalizedLedger.donationCredit; + const netCount = totals.plusSuccess - credit; + const sinceLastPrompt = netCount - normalizedLedger.promptBaseline; + return { + ...totals, + credit, + netCount, + sinceLastPrompt, + shouldPrompt: sinceLastPrompt >= PLUS_CONTRIBUTION_PROMPT_THRESHOLD, + }; +} + +function shouldShowPlusContributionPrompt(records = [], plusModeEnabled = false, ledger = getPlusContributionPromptLedger()) { + return Boolean(plusModeEnabled) + && getPlusContributionPromptProgress(records, ledger).shouldPrompt; +} + +function markPlusContributionPromptShown(records = [], ledger = getPlusContributionPromptLedger()) { + const progress = getPlusContributionPromptProgress(records, ledger); + const nextLedger = { + ...normalizePlusContributionPromptLedger(ledger), + promptBaseline: progress.netCount, + }; + setPlusContributionPromptLedger(nextLedger); + return nextLedger; +} + +function addPlusContributionPromptCredit(credit, ledger = getPlusContributionPromptLedger()) { + const normalizedLedger = normalizePlusContributionPromptLedger(ledger); + const nextLedger = { + ...normalizedLedger, + donationCredit: normalizedLedger.donationCredit + Math.max(0, normalizePlusContributionPromptNumber(credit)), + }; + setPlusContributionPromptLedger(nextLedger); + return nextLedger; +} + +function getPlusContributionSupportImageUrl() { + if (typeof chrome !== 'undefined' && chrome.runtime?.getURL) { + return chrome.runtime.getURL('docs/images/微信.png'); + } + return '../docs/images/微信.png'; +} + +function buildPlusContributionSupportPromptHtml() { + const imageUrl = getPlusContributionSupportImageUrl(); + return [ + '您觉得这个 Plus 功能怎么样?您的账户数量应该已经够个人使用啦。', + '可以打开贡献给作者贡献几个账号,以便于让作者开发更好的功能出来吗?或者打赏一下作者?', + `微信打赏二维码`, + ].join(''); +} + +function openPlusContributionSupportModal() { + return openActionModal({ + title: 'Plus 功能使用反馈', + messageHtml: buildPlusContributionSupportPromptHtml(), + actions: [ + { id: null, label: '取消', variant: 'btn-ghost' }, + { id: 'contribute', label: '去贡献账号', variant: 'btn-outline' }, + { id: 'donated', label: '已打赏', variant: 'btn-primary' }, + ], + }); +} + +async function maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled) { + const records = Array.isArray(latestState?.accountRunHistory) ? latestState.accountRunHistory : []; + if (!shouldShowPlusContributionPrompt(records, plusModeEnabled)) { + return true; + } + + const choice = await openPlusContributionSupportModal(); + const ledger = markPlusContributionPromptShown(records); + if (choice === 'donated') { + addPlusContributionPromptCredit(PLUS_CONTRIBUTION_DONATION_CREDIT, ledger); + showToast('感谢打赏支持,已延后下一次 Plus 提醒。', 'success', 2200); + return true; + } + if (choice === 'contribute') { + openExternalUrl(getContributionPortalUrl()); + showToast('已打开贡献页面,可以按页面提示贡献 Plus 账号。', 'info', 2200); + return false; + } + return true; +} + async function openAutoSkipFailuresConfirmModal() { const result = await openConfirmModalWithOption({ title: '自动重试说明', @@ -4521,6 +4666,9 @@ async function startAutoRunFromCurrentSettings() { if (lockedRunCount > 0) { inputRunCount.value = String(lockedRunCount); } + const plusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled + ? Boolean(inputPlusModeEnabled.checked) + : Boolean(currentPlusModeEnabled || latestState?.plusModeEnabled); let mode = 'restart'; const autoRunSkipFailures = inputAutoSkipFailures.checked; const contributionNickname = String(inputContributionNickname?.value || '').trim(); @@ -4540,6 +4688,11 @@ async function startAutoRunFromCurrentSettings() { mode = choice; } + const confirmedPlusContributionPrompt = await maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled); + if (!confirmedPlusContributionPrompt) { + return false; + } + if (shouldWarnAutoRunFallbackRisk(totalRuns, autoRunSkipFailures) && !isAutoRunFallbackRiskPromptDismissed()) { const result = await openAutoRunFallbackRiskConfirmModal(totalRuns); @@ -4551,9 +4704,6 @@ async function startAutoRunFromCurrentSettings() { } } - const plusModeEnabled = typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled - ? Boolean(inputPlusModeEnabled.checked) - : Boolean(currentPlusModeEnabled || latestState?.plusModeEnabled); if (shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) && !isAutoRunPlusRiskPromptDismissed()) { const result = await openPlusAutoRunRiskConfirmModal(totalRuns); diff --git a/tests/auto-step-random-delay.test.js b/tests/auto-step-random-delay.test.js index 6bdbb16..0683996 100644 --- a/tests/auto-step-random-delay.test.js +++ b/tests/auto-step-random-delay.test.js @@ -45,11 +45,15 @@ const bundle = [ 'const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0;', 'const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;', 'const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };', + "const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);", + 'function getStepDefinitionForState(step, state = {}) { return state.definitions?.[step] || null; }', extractFunction('normalizeAutoStepDelaySeconds'), extractFunction('resolveLegacyAutoStepDelaySeconds'), + extractFunction('getStepExecutionKeyForState'), + extractFunction('getAutoRunPreExecutionDelayMs'), ].join('\n'); -const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds };`)(); +const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds, getAutoRunPreExecutionDelayMs };`)(); assert.strictEqual( api.normalizeAutoStepDelaySeconds(''), @@ -123,4 +127,24 @@ assert.strictEqual( 'empty legacy settings should migrate to no delay' ); +assert.strictEqual( + api.getAutoRunPreExecutionDelayMs(6, { + definitions: { + 6: { key: 'plus-checkout-create' }, + }, + }), + 20000, + 'Plus checkout create should wait before step execution' +); + +assert.strictEqual( + api.getAutoRunPreExecutionDelayMs(6, { + definitions: { + 6: { key: 'clear-login-cookies' }, + }, + }), + 0, + 'normal step 6 should not inherit the Plus checkout pre-wait' +); + console.log('auto step delay tests passed'); diff --git a/tests/background-account-run-history-module.test.js b/tests/background-account-run-history-module.test.js index cdb58da..0447413 100644 --- a/tests/background-account-run-history-module.test.js +++ b/tests/background-account-run-history-module.test.js @@ -87,6 +87,8 @@ test('account run history helper upgrades old records, keeps stopped items and s totalRuns: 10, attemptRun: 3, }, + plusModeEnabled: false, + contributionMode: false, }); const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。'); @@ -138,6 +140,39 @@ test('account run history helper upgrades old records, keeps stopped items and s assert.equal(normalizedStoppedRecord.failedStep, 7); }); +test('account run history records preserve Plus and contribution mode flags', () => { + const source = fs.readFileSync('background/account-run-history.js', 'utf8'); + const globalScope = {}; + const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope); + + const helpers = api.createAccountRunHistoryHelpers({ + chrome: { storage: { local: { get: async () => ({}), set: async () => {} } } }, + getState: async () => ({}), + normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(), + }); + + const record = helpers.buildAccountRunHistoryRecord({ + email: 'plus@example.com', + password: 'secret', + plusModeEnabled: true, + contributionMode: true, + }, 'success'); + + assert.equal(record.plusModeEnabled, true); + assert.equal(record.contributionMode, true); + + const normalized = helpers.normalizeAccountRunHistoryRecord({ + email: 'plus@example.com', + password: 'secret', + finalStatus: 'success', + plusModeEnabled: true, + contributionMode: true, + }); + + assert.equal(normalized.plusModeEnabled, true); + assert.equal(normalized.contributionMode, true); +}); + test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => { const source = fs.readFileSync('background/account-run-history.js', 'utf8'); const globalScope = {}; diff --git a/tests/plus-checkout-create-wait.test.js b/tests/plus-checkout-create-wait.test.js index 8253d6c..3fd9f41 100644 --- a/tests/plus-checkout-create-wait.test.js +++ b/tests/plus-checkout-create-wait.test.js @@ -6,7 +6,7 @@ const source = fs.readFileSync('background/steps/create-plus-checkout.js', 'utf8 const globalScope = {}; const api = new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutCreate;`)(globalScope); -test('Plus checkout create waits 20 seconds before completing step 6', async () => { +test('Plus checkout create does not wait 20 seconds after opening checkout page', async () => { const events = []; const executor = api.createPlusCheckoutCreateExecutor({ addLog: async (message, level = 'info') => { @@ -45,11 +45,12 @@ test('Plus checkout create waits 20 seconds before completing step 6', async () await executor.executePlusCheckoutCreate(); const sleepEvents = events.filter((event) => event.type === 'sleep'); - assert.deepStrictEqual(sleepEvents.map((event) => event.ms), [1000, 1000, 20000]); + assert.deepStrictEqual(sleepEvents.map((event) => event.ms), [1000, 1000]); - const fixedWaitIndex = events.findIndex((event) => event.type === 'sleep' && event.ms === 20000); const completeIndex = events.findIndex((event) => event.type === 'complete'); - assert.ok(fixedWaitIndex > -1); - assert.ok(completeIndex > fixedWaitIndex); - assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒/.test(event.message)), true); + const readyLogIndex = events.findIndex((event) => event.type === 'log' && /Plus Checkout 页面已就绪/.test(event.message)); + assert.ok(readyLogIndex > -1); + assert.ok(completeIndex > readyLogIndex); + assert.equal(events.some((event) => event.type === 'sleep' && event.ms === 20000), false); + assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒后继续下一步/.test(event.message)), false); }); diff --git a/tests/sidepanel-auto-run-content-refresh.test.js b/tests/sidepanel-auto-run-content-refresh.test.js index dc6ed2f..e908664 100644 --- a/tests/sidepanel-auto-run-content-refresh.test.js +++ b/tests/sidepanel-auto-run-content-refresh.test.js @@ -57,6 +57,7 @@ function createApi({ plusRiskEnabled = false, plusRiskConfirmed = true, plusRiskDismissPrompt = false, + plusContributionImpl, } = {}) { const bundle = extractFunction('startAutoRunFromCurrentSettings'); @@ -110,6 +111,9 @@ async function openPlusAutoRunRiskConfirmModal(totalRuns) { function setAutoRunPlusRiskPromptDismissed(dismissed) { events.push({ type: 'plus-risk-dismiss', dismissed }); } +async function maybeShowPlusContributionPromptBeforeAutoRun(plusModeEnabled) { + ${plusContributionImpl ? 'return (' + plusContributionImpl + ')(plusModeEnabled, events);' : 'return true;'} +} function normalizeAutoDelayMinutes(value) { return Number(value) || 30; } async function refreshContributionContentHint() { events.push({ type: 'refresh' }); @@ -216,3 +220,22 @@ test('startAutoRunFromCurrentSettings aborts when Plus risk warning is declined' ['refresh', 'confirm-check', 'plus-risk-modal'] ); }); + +test('startAutoRunFromCurrentSettings aborts when Plus contribution prompt opens contribution page', async () => { + const api = createApi({ + plusModeEnabled: true, + plusContributionImpl: `async (plusModeEnabled, events) => { + events.push({ type: 'plus-contribution-modal', plusModeEnabled }); + return false; + }`, + }); + + const result = await api.startAutoRunFromCurrentSettings(); + + assert.equal(result, false); + assert.deepEqual( + api.getEvents().map((entry) => entry.type), + ['refresh', 'confirm-check', 'plus-contribution-modal'] + ); + assert.equal(api.getEvents()[2].plusModeEnabled, true); +}); diff --git a/tests/sidepanel-plus-contribution-prompt.test.js b/tests/sidepanel-plus-contribution-prompt.test.js new file mode 100644 index 0000000..ae33e29 --- /dev/null +++ b/tests/sidepanel-plus-contribution-prompt.test.js @@ -0,0 +1,291 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +function extractBundle(names) { + return names.map((name) => extractFunction(name)).join('\n'); +} + +function createPlusRecords(count, contributionMode = false) { + return Array.from({ length: count }, (_, index) => ({ + recordId: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`, + email: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`, + finalStatus: 'success', + plusModeEnabled: true, + contributionMode, + })); +} + +test('Plus contribution prompt starts every five normal Plus successes', () => { + const bundle = extractBundle([ + 'normalizePlusContributionPromptNumber', + 'normalizePlusContributionPromptLedger', + 'isSuccessfulPlusAccountRecord', + 'getPlusContributionPromptTotals', + 'getPlusContributionPromptProgress', + 'shouldShowPlusContributionPrompt', + ]); + + const api = new Function(` +const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5; +const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5; +${bundle} +return { + getPlusContributionPromptProgress, + shouldShowPlusContributionPrompt, +}; +`)(); + + assert.equal( + api.shouldShowPlusContributionPrompt(createPlusRecords(4), true, { promptBaseline: 0, donationCredit: 0 }), + false + ); + assert.equal( + api.shouldShowPlusContributionPrompt(createPlusRecords(5), true, { promptBaseline: 0, donationCredit: 0 }), + true + ); + assert.equal( + api.shouldShowPlusContributionPrompt(createPlusRecords(8), false, { promptBaseline: 0, donationCredit: 0 }), + false + ); +}); + +test('Plus contribution success and donated credit delay the next prompt', () => { + const bundle = extractBundle([ + 'normalizePlusContributionPromptNumber', + 'normalizePlusContributionPromptLedger', + 'isSuccessfulPlusAccountRecord', + 'getPlusContributionPromptTotals', + 'getPlusContributionPromptProgress', + 'shouldShowPlusContributionPrompt', + ]); + + const api = new Function(` +const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5; +const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5; +${bundle} +return { shouldShowPlusContributionPrompt }; +`)(); + + const afterPromptLedger = { promptBaseline: 5, donationCredit: 0 }; + assert.equal( + api.shouldShowPlusContributionPrompt( + [...createPlusRecords(14), ...createPlusRecords(1, true)], + true, + afterPromptLedger + ), + false + ); + assert.equal( + api.shouldShowPlusContributionPrompt( + [...createPlusRecords(15), ...createPlusRecords(1, true)], + true, + afterPromptLedger + ), + true + ); + + const donatedLedger = { promptBaseline: 5, donationCredit: 20 }; + assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(29), true, donatedLedger), false); + assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(30), true, donatedLedger), true); +}); + +test('Plus contribution support modal includes WeChat image and expected actions', async () => { + const bundle = extractBundle([ + 'getPlusContributionSupportImageUrl', + 'buildPlusContributionSupportPromptHtml', + 'openPlusContributionSupportModal', + ]); + + const api = new Function(` +let capturedOptions = null; +const chrome = { runtime: { getURL: (path) => 'chrome-extension://test/' + path } }; +function escapeHtml(value) { return String(value || ''); } +async function openActionModal(options) { + capturedOptions = options; + return 'donated'; +} +${bundle} +return { + openPlusContributionSupportModal, + getCapturedOptions() { + return capturedOptions; + }, +}; +`)(); + + const choice = await api.openPlusContributionSupportModal(); + const options = api.getCapturedOptions(); + + assert.equal(choice, 'donated'); + assert.equal(options.title, 'Plus 功能使用反馈'); + assert.match(options.messageHtml, /docs\/images\/微信\.png/); + assert.deepEqual(options.actions.map((action) => action.label), ['取消', '去贡献账号', '已打赏']); +}); + +test('Plus contribution prompt marks shown and donated choice adds twenty credits', async () => { + const bundle = extractBundle([ + 'normalizePlusContributionPromptNumber', + 'normalizePlusContributionPromptLedger', + 'getPlusContributionPromptLedger', + 'setPlusContributionPromptLedger', + 'isSuccessfulPlusAccountRecord', + 'getPlusContributionPromptTotals', + 'getPlusContributionPromptProgress', + 'shouldShowPlusContributionPrompt', + 'markPlusContributionPromptShown', + 'addPlusContributionPromptCredit', + 'maybeShowPlusContributionPromptBeforeAutoRun', + ]); + + const api = new Function('records', ` +const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger'; +const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5; +const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5; +const PLUS_CONTRIBUTION_DONATION_CREDIT = 20; +const latestState = { accountRunHistory: records }; +const storage = {}; +const events = []; +const localStorage = { + getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; }, + setItem(key, value) { storage[key] = String(value); }, +}; +async function openPlusContributionSupportModal() { + events.push({ type: 'modal' }); + return 'donated'; +} +function showToast(message, type) { + events.push({ type: 'toast', message, toastType: type }); +} +function openExternalUrl(url) { + events.push({ type: 'open', url }); +} +function getContributionPortalUrl() { + return 'https://apikey.qzz.io'; +} +${bundle} +return { + maybeShowPlusContributionPromptBeforeAutoRun, + getLedger() { + return JSON.parse(storage[PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY] || '{}'); + }, + getEvents() { + return events; + }, +}; +`)(createPlusRecords(5)); + + const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true); + + assert.equal(result, true); + assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'toast']); + assert.deepEqual(api.getLedger(), { + promptBaseline: 5, + donationCredit: 20, + }); +}); + +test('Plus contribution prompt opens portal and aborts normal auto run when contribute is chosen', async () => { + const bundle = extractBundle([ + 'normalizePlusContributionPromptNumber', + 'normalizePlusContributionPromptLedger', + 'getPlusContributionPromptLedger', + 'setPlusContributionPromptLedger', + 'isSuccessfulPlusAccountRecord', + 'getPlusContributionPromptTotals', + 'getPlusContributionPromptProgress', + 'shouldShowPlusContributionPrompt', + 'markPlusContributionPromptShown', + 'addPlusContributionPromptCredit', + 'maybeShowPlusContributionPromptBeforeAutoRun', + ]); + + const api = new Function('records', ` +const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger'; +const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5; +const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5; +const PLUS_CONTRIBUTION_DONATION_CREDIT = 20; +const latestState = { accountRunHistory: records }; +const storage = {}; +const events = []; +const localStorage = { + getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; }, + setItem(key, value) { storage[key] = String(value); }, +}; +async function openPlusContributionSupportModal() { + events.push({ type: 'modal' }); + return 'contribute'; +} +function showToast(message, type) { + events.push({ type: 'toast', message, toastType: type }); +} +function openExternalUrl(url) { + events.push({ type: 'open', url }); +} +function getContributionPortalUrl() { + return 'https://apikey.qzz.io'; +} +${bundle} +return { + maybeShowPlusContributionPromptBeforeAutoRun, + getEvents() { + return events; + }, +}; +`)(createPlusRecords(5)); + + const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true); + + assert.equal(result, false); + assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'open', 'toast']); + assert.equal(api.getEvents()[1].url, 'https://apikey.qzz.io'); +});