fix: resolve step 9 profile fallback with latest dev

This commit is contained in:
QLHazyCoder
2026-05-06 01:51:04 +08:00
132 changed files with 28350 additions and 4307 deletions
+12
View File
@@ -9,7 +9,10 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(api.normalizeCountryCode('Deutschland'), 'DE');
assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU');
assert.equal(api.normalizeCountryCode('印尼'), 'ID');
assert.equal(api.normalizeCountryCode('日本'), 'JP');
assert.equal(api.normalizeCountryCode('韩国'), 'KR');
assert.equal(api.normalizeCountryCode('South Korea'), 'KR');
assert.equal(api.normalizeCountryCode('unknown'), '');
const deSeed = api.getAddressSeedForCountry('DE');
@@ -22,7 +25,16 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(fallbackSeed.countryCode, 'AU');
assert.equal(fallbackSeed.fallback.region, 'New South Wales');
const idSeed = api.getAddressSeedForCountry('Indonesia');
assert.equal(idSeed.countryCode, 'ID');
assert.equal(idSeed.fallback.region, 'DKI Jakarta');
const jpSeed = api.getAddressSeedForCountry('日本');
assert.equal(jpSeed.countryCode, 'JP');
assert.equal(jpSeed.fallback.region, 'Tokyo');
const krSeed = api.getAddressSeedForCountry('Korea');
assert.equal(krSeed.countryCode, 'KR');
assert.equal(krSeed.fallback.region, 'Seoul');
assert.match(krSeed.fallback.postalCode, /^\d{5}$/);
});
+334
View File
@@ -653,6 +653,172 @@ test('auto-run controller skips user_already_exists failures to the next round i
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller skips step 4 repeated 405 recovery failures to the next round', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isPlusCheckoutNonFreeTrialFailure: () => false,
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStep4Route405RecoveryLimitFailure: (error) => /STEP4_405_RECOVERY_LIMIT::/.test(error?.message || String(error || '')),
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('STEP4_405_RECOVERY_LIMIT::步骤 4:检测到 405 错误页面,已连续点击“重试”恢复 3/3 次仍未恢复,当前轮将结束并进入下一轮。');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(2, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, 'step 4 repeated 405 failure should skip the current round and continue with the next round');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'step 4 repeated 405 should not retry the same round');
assert.equal(events.accountRecords.length, 1, 'step 4 repeated 405 should persist a failed round record');
assert.equal(events.accountRecords[0].status, 'failed');
assert.match(events.accountRecords[0].reason, /STEP4_405_RECOVERY_LIMIT::/);
assert.ok(events.logs.some(({ message }) => /连续 405.*继续下一轮|继续下一轮/.test(message)));
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller keeps retrying the same custom mail provider pool email until success', async () => {
const events = {
logs: [],
@@ -817,3 +983,171 @@ test('auto-run controller keeps retrying the same custom mail provider pool emai
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller retries 5sim rate limit failures instead of treating current add-phone page as fatal', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
sleeps: [],
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
isPhoneSmsPlatformRateLimitFailure: (error) => /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(error?.message || String(error || '')),
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。当前页面 https://auth.openai.com/add-phone');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, '5sim rate limit should use same-round retry instead of add-phone fatal skip');
assert.equal(events.broadcasts.filter(({ phase }) => phase === 'retrying').length, 1);
assert.equal(events.accountRecords.length, 0);
assert.ok(events.logs.some(({ message }) => /自动重试/.test(message)));
assert.equal(events.logs.some(({ message }) => /触发 add-phone\/手机号页/.test(message)), false);
assert.equal(events.sleeps.filter((ms) => ms === 3000).length, 1);
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
@@ -105,6 +105,15 @@ let currentState = {
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
signupMethod: 'phone',
resolvedSignupMethod: 'phone',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
signupPhoneNumber: '+6612345',
signupPhoneActivation: { activationId: 'signup-activation', phoneNumber: '+6612345' },
signupPhoneCompletedActivation: { activationId: 'signup-completed', phoneNumber: '+6612345' },
signupPhoneVerificationRequestedAt: 123456,
signupPhoneVerificationPurpose: 'signup',
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: 'demo@gmail.com',
@@ -162,6 +171,15 @@ async function resetState() {
autoRunDelayEnabled: prev.autoRunDelayEnabled,
autoRunDelayMinutes: prev.autoRunDelayMinutes,
autoStepDelaySeconds: prev.autoStepDelaySeconds,
signupMethod: prev.signupMethod,
resolvedSignupMethod: null,
accountIdentifierType: null,
accountIdentifier: '',
signupPhoneNumber: '',
signupPhoneActivation: null,
signupPhoneCompletedActivation: null,
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
mailProvider: prev.mailProvider,
emailGenerator: prev.emailGenerator,
gmailBaseEmail: prev.gmailBaseEmail,
@@ -335,6 +353,15 @@ return {
assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.signupMethod, 'phone', 'signup method setting should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.resolvedSignupMethod, null, 'resolved signup method should be cleared before the next run freezes it again');
assert.strictEqual(snapshot.currentState.accountIdentifierType, null, 'account identifier type should be runtime-only');
assert.strictEqual(snapshot.currentState.accountIdentifier, '', 'account identifier should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneNumber, '', 'signup phone number should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneActivation, null, 'signup phone activation should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneCompletedActivation, null, 'completed signup phone activation should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationRequestedAt, null, 'signup phone request time should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationPurpose, '', 'signup phone purpose should be runtime-only');
assert.deepStrictEqual(
snapshot.currentState.reusablePhoneActivation,
{
@@ -66,6 +66,7 @@ test('auto-run stops step4 restart path when mail2925 ends the current thread',
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -107,6 +108,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
+6
View File
@@ -66,6 +66,7 @@ test('auto-run restarts from step 1 with the same email after step 4 failure', a
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -111,6 +112,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
@@ -215,6 +217,7 @@ test('auto-run does not restart step 4 current attempt when user_already_exists
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -256,6 +259,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
@@ -336,6 +340,7 @@ test('auto-run skips steps 4/5 when step 2 has already marked registration chain
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -376,6 +381,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
+24
View File
@@ -74,6 +74,7 @@ function createHarness(options = {}) {
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const LOG_PREFIX = '[test]';
const chrome = {
tabs: {
@@ -93,6 +94,7 @@ async function addLog(message, level = 'info') {
}
async function ensureAutoEmailReady() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function broadcastAutoRunStatus() {}
async function getState() {
return {
@@ -147,6 +149,10 @@ function getLoginAuthStateLabel(state) {
function getErrorMessage(error) {
return error?.message || String(error || '');
}
function isPhoneSmsPlatformRateLimitFailure(error) {
const message = getErrorMessage(error);
return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message);
}
async function getLoginAuthStateFromContent() {
return ${JSON.stringify(authState)};
}
@@ -254,6 +260,24 @@ test('auto-run does not restart step 7 when phone verification exhausted replace
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run post-login restart decision does not treat 5sim rate limit on add-phone page as add-phone fatal', async () => {
const harness = createHarness({
failureStep: 9,
failureBudget: 1,
failureMessage: 'FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。',
authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' },
});
const result = await harness.runAndCaptureError();
assert.ok(result?.error);
assert.equal(result.events.invalidations.length, 0);
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
assert.ok(!result.events.logs.some(({ message }) => /进入 add-phone/.test(message)));
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
const harness = createHarness({
failureStep: 9,
@@ -54,12 +54,16 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeHotmailLocalBaseUrl'),
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizePhoneSmsProviderOrder'),
extractFunction('normalizeSignupMethod'),
extractFunction('normalizeFiveSimCountryCode'),
extractFunction('normalizeFiveSimCountryOrder'),
extractFunction('normalizeNexSmsCountryId'),
extractFunction('normalizeNexSmsCountryOrder'),
extractFunction('normalizeNexSmsServiceCode'),
extractFunction('normalizePhonePreferredActivation'),
extractFunction('normalizePhoneVerificationReplacementLimit'),
extractFunction('normalizePhoneCodeWaitSeconds'),
extractFunction('normalizePhoneCodeTimeoutWindows'),
@@ -67,6 +71,13 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizePhoneCodePollMaxRounds'),
extractFunction('normalizeHeroSmsMaxPrice'),
extractFunction('normalizeHeroSmsCountryFallback'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizeFiveSimCountryId'),
extractFunction('normalizeFiveSimCountryLabel'),
extractFunction('normalizeFiveSimOperator'),
extractFunction('normalizeFiveSimMaxPrice'),
extractFunction('normalizeFiveSimCountryFallback'),
extractFunction('normalizeSub2ApiGroupNames'),
extractFunction('normalizePersistentSettingValue'),
].join('\n');
@@ -98,8 +109,52 @@ const VERIFICATION_RESEND_COUNT_MIN = 0;
const VERIFICATION_RESEND_COUNT_MAX = 20;
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms'];
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const FIVE_SIM_COUNTRY_ID = 'vietnam';
const FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const self = {
GoPayUtils: {
normalizeGoPayCountryCode(value) {
const digits = String(value || '').replace(/\\D/g, '');
return digits ? \`+\${digits}\` : '+86';
},
normalizeGoPayPhone(value) {
return String(value || '').trim().replace(/[^\\d+]/g, '');
},
normalizeGoPayOtp(value) {
return String(value || '').trim().replace(/[^\\d]/g, '');
},
normalizeGoPayPin(value) {
return String(value || '').trim().replace(/[^\\d]/g, '');
},
normalizeGpcHelperBaseUrl(value) {
return String(value || '')
.trim()
.replace(/\\/+$/g, '')
.replace(/\\/api\\/checkout\\/start$/i, '')
.replace(/\\/api\\/gopay\\/(?:otp|pin)$/i, '')
.replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, '');
},
},
};
const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
gopayHelperApiUrl: 'https://gopay.hwork.pro',
mailProvider: '163',
};
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
@@ -130,8 +185,18 @@ return {
assert.equal(api.normalizePersistentSettingValue('accountRunHistoryTextEnabled', 1), true);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationEnabled', 1), true);
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gopay'), 'gopay');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal');
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gopay.hwork.pro/api/checkout/start '),
'https://gopay.hwork.pro'
);
assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gopay.hwork.pro');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCardKey', ' card_123 '), 'card_123');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456');
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '7'), 7);
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9);
@@ -142,7 +207,25 @@ return {
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
assert.equal(api.normalizePersistentSettingValue('heroSmsCountryId', 0), 0);
assert.equal(api.normalizePersistentSettingValue('heroSmsPreferredPrice', '0.051234'), '0.0512');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'phone'), 'phone');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'unknown'), 'email');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', '5SIM'), '5sim');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'NEXSMS'), 'nexsms');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'unknown'), 'hero-sms');
assert.deepStrictEqual(api.normalizePersistentSettingValue('phoneSmsProviderOrder', ['nexsms', '5sim', 'nexsms']), ['nexsms', '5sim']);
assert.equal(api.normalizePersistentSettingValue('fiveSimApiKey', ' demo-five '), ' demo-five ');
assert.equal(api.normalizePersistentSettingValue('fiveSimProduct', ' OpenAI! '), 'openai');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ' England! '), 'england');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ''), 'vietnam');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryLabel', ''), '越南 (Vietnam)');
assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '9.87654'), '9.8765');
assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '-1'), '');
assert.equal(api.normalizePersistentSettingValue('fiveSimOperator', ''), 'any');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('fiveSimCountryFallback', [{ id: 'usa', label: 'USA' }, 'thailand:Thailand']),
[{ id: 'usa', label: 'USA' }, { id: 'thailand', label: 'Thailand' }]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
@@ -167,6 +250,10 @@ return {
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
'proxy-a'
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('sub2apiGroupNames', [' codex ', 'openai-plus', 'CODEX']),
['codex', 'openai-plus']
);
assert.equal(
api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
'http://localhost:8080/admin/accounts'
@@ -187,8 +274,36 @@ return {
api.normalizePersistentSettingValue('fiveSimCountryOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('fiveSimCountryOrder', [' Thailand ', 'vietnam', 'thailand']),
['thailand', 'vietnam']
);
assert.equal(api.normalizePersistentSettingValue('nexSmsApiKey', ' demo-nex '), ' demo-nex ');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('nexSmsCountryOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('nexSmsCountryOrder', [1, '6', 1]),
[1, 6]
);
assert.equal(api.normalizePersistentSettingValue('nexSmsServiceCode', ' OT! '), 'ot');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('phonePreferredActivation', {
provider: 'nexsms',
activationId: 'abc',
phoneNumber: '+6612345',
successfulUses: 2,
maxUses: 3,
}),
{
provider: 'nexsms',
activationId: 'abc',
phoneNumber: '+6612345',
successfulUses: 2,
maxUses: 3,
countryId: null,
countryLabel: '',
}
);
});
@@ -73,7 +73,10 @@ test('account run history helper upgrades old records, keeps stopped items and s
);
assert.deepStrictEqual(record, {
recordId: 'latest@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'latest@example.com',
email: 'latest@example.com',
phoneNumber: '',
password: 'secret',
finalStatus: 'failed',
finishedAt: record.finishedAt,
@@ -140,6 +143,187 @@ test('account run history helper upgrades old records, keeps stopped items and s
assert.equal(normalizedStoppedRecord.failedStep, 7);
});
test('account run history helper accepts phone-only records without forcing email or password', () => {
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({
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
signupPhoneNumber: '+6612345',
password: '',
}, 'success');
assert.deepStrictEqual(record, {
recordId: 'phone:+6612345',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
email: '',
phoneNumber: '+6612345',
password: '',
finalStatus: 'success',
finishedAt: record.finishedAt,
retryCount: 0,
failureLabel: '流程完成',
failureDetail: '',
failedStep: null,
source: 'manual',
autoRunContext: null,
plusModeEnabled: false,
contributionMode: false,
});
const normalized = helpers.normalizeAccountRunHistoryRecord({
recordId: 'phone:+6612345',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
phoneNumber: '+6612345',
finalStatus: 'failed',
failureDetail: '步骤 8:手机号验证码超时。',
});
assert.equal(normalized.recordId, 'phone:+6612345');
assert.equal(normalized.accountIdentifierType, 'phone');
assert.equal(normalized.accountIdentifier, '+6612345');
assert.equal(normalized.email, '');
assert.equal(normalized.phoneNumber, '+6612345');
assert.equal(normalized.password, '');
assert.equal(normalized.finalStatus, 'failed');
});
test('account run history merges email and phone identities from the same run', async () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
let storedHistory = [
{
recordId: 'phone:+447799342687',
accountIdentifierType: 'phone',
accountIdentifier: '+447799342687',
phoneNumber: '+44 7799 342687',
email: '',
password: '',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:30:00.000Z',
failureDetail: '步骤 2 已使用手机号,流程尚未完成。',
},
{
recordId: 'tmp@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
phoneNumber: '',
password: 'old',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:31:00.000Z',
failureDetail: '步骤 2 已使用邮箱,流程尚未完成。',
},
];
const helpers = api.createAccountRunHistoryHelpers({
chrome: {
storage: {
local: {
get: async () => ({ accountRunHistory: storedHistory }),
set: async (payload) => {
storedHistory = payload.accountRunHistory;
},
},
},
},
getState: async () => ({}),
normalizeAccountRunHistoryHelperBaseUrl: () => '',
});
const failedRecord = helpers.buildAccountRunHistoryRecord({
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
password: 'secret',
currentPhoneActivation: {
activationId: 'a1',
phoneNumber: '+44 7799 342687',
},
}, 'step9_failed', '步骤 9:手机号验证失败。');
assert.equal(failedRecord.accountIdentifierType, 'email');
assert.equal(failedRecord.accountIdentifier, 'tmp@example.com');
assert.equal(failedRecord.phoneNumber, '+44 7799 342687');
const successRecord = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
phoneNumber: '447799342687',
password: 'secret',
accountRunHistoryHelperBaseUrl: '',
});
assert.equal(successRecord.recordId, 'tmp@example.com');
assert.equal(successRecord.email, 'tmp@example.com');
assert.equal(successRecord.phoneNumber, '447799342687');
assert.equal(storedHistory.length, 1);
assert.equal(storedHistory[0].recordId, 'tmp@example.com');
assert.equal(storedHistory[0].finalStatus, 'success');
});
test('account run history keeps phone as primary identity when phone signup later binds email', async () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
let storedHistory = [{
recordId: 'phone:+447700900123',
accountIdentifierType: 'phone',
accountIdentifier: '+447700900123',
phoneNumber: '+447700900123',
email: '',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:31:00.000Z',
failureDetail: '步骤 2 已使用手机号,流程尚未完成。',
}];
const helpers = api.createAccountRunHistoryHelpers({
chrome: {
storage: {
local: {
get: async () => ({ accountRunHistory: storedHistory }),
set: async (payload) => {
storedHistory = payload.accountRunHistory;
},
},
},
},
getState: async () => ({}),
normalizeAccountRunHistoryHelperBaseUrl: () => '',
});
const record = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'phone',
accountIdentifier: '+447700900123',
signupPhoneNumber: '+447700900123',
email: 'bound@example.com',
password: 'secret',
accountRunHistoryHelperBaseUrl: '',
});
assert.equal(record.recordId, 'phone:+447700900123');
assert.equal(record.accountIdentifierType, 'phone');
assert.equal(record.accountIdentifier, '+447700900123');
assert.equal(record.email, 'bound@example.com');
assert.equal(record.phoneNumber, '+447700900123');
assert.equal(storedHistory.length, 1);
assert.equal(storedHistory[0].recordId, 'phone:+447700900123');
assert.equal(storedHistory[0].finalStatus, 'success');
});
test('account run history records preserve Plus and contribution mode flags', () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
+16 -6
View File
@@ -773,8 +773,8 @@ ${bundle}
return { refreshOAuthUrlBeforeStep6 };
`)();
globalThis.addLog = async (message) => {
calls.push({ type: 'log', message });
globalThis.addLog = async (message, level, options) => {
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow(options) {
@@ -801,7 +801,12 @@ return { refreshOAuthUrlBeforeStep6 };
assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001');
assert.deepStrictEqual(calls, [
{ type: 'log', message: '步骤 7contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...' },
{
type: 'log',
message: 'contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
{
type: 'contribution',
options: {
@@ -839,8 +844,8 @@ ${bundle}
return { refreshOAuthUrlBeforeStep6 };
`)();
globalThis.addLog = async (message) => {
calls.push({ type: 'log', message });
globalThis.addLog = async (message, level, options) => {
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow() {
@@ -868,7 +873,12 @@ return { refreshOAuthUrlBeforeStep6 };
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
assert.deepStrictEqual(calls, [
{ type: 'log', message: '步骤 7contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
{
type: 'log',
message: 'contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
{ type: 'panel' },
{
type: 'step',
@@ -54,6 +54,7 @@ function extractFunction(name) {
const bundle = [
extractFunction('normalizeEmailGenerator'),
extractFunction('normalizeCustomEmailPool'),
extractFunction('normalizeCustomEmailPoolEntryObjects'),
extractFunction('getCustomEmailPool'),
extractFunction('getCustomEmailPoolEmailForRun'),
extractFunction('getCustomMailProviderPool'),
@@ -122,3 +123,18 @@ test('background selects the matching custom provider pool email for the current
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 3), 'third@example.com');
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 4), '');
});
test('background derives active custom email pool from structured entries', () => {
const api = createApi();
const state = {
customEmailPoolEntries: [
{ id: 'a', email: 'one@example.com', enabled: true, used: false },
{ id: 'b', email: 'two@example.com', enabled: true, used: true },
{ id: 'c', email: 'three@example.com', enabled: false, used: false },
],
};
assert.deepEqual(api.getCustomEmailPool(state), ['one@example.com']);
assert.equal(api.getCustomEmailPoolEmailForRun(state, 1), 'one@example.com');
assert.equal(api.getCustomEmailPoolEmailForRun(state, 2), '');
});
+119
View File
@@ -19,6 +19,18 @@ const IP_PROXY_FETCH_TIMEOUT_MS = 20000;
const IP_PROXY_SETTINGS_SCOPE = 'regular';
const IP_PROXY_BYPASS_LIST = ['<local>', 'localhost', '127.0.0.1'];
const IP_PROXY_ROUTE_ALL_TRAFFIC = true;
const IP_PROXY_FORCE_DIRECT_HOST_PATTERNS = [
'pm-redirects.stripe.com',
'*.pm-redirects.stripe.com',
'hwork.pro',
'*.hwork.pro',
'auth.openai.com',
'auth0.openai.com',
'accounts.openai.com',
'luckyous.com',
'*.luckyous.com',
];
const IP_PROXY_FORCE_DIRECT_FALLBACK = 'PROXY 127.0.0.1:7897';
const IP_PROXY_ACCOUNT_LIST_ENABLED = ${accountListEnabled ? 'true' : 'false'};
const IP_PROXY_TARGET_HOST_PATTERNS = [
'openai.com',
@@ -32,11 +44,17 @@ ${coreSource}
return {
applyExitRegionExpectation,
buildIpProxyPacScript,
buildIpProxyRoutingStatePatch,
applyTargetReachabilityExpectation,
getAccountModeProxyPoolFromState,
normalizeIpProxyAccountList,
normalizeProxyPoolEntries,
parseProxyExitProbePayload,
parseIpProxyLine,
resolveExitProbeEndpoints,
resolveIpProxyAutoSwitchThreshold,
resolveTargetReachabilityEndpoints,
shouldEnableIpProxyLeakGuardForStatus,
};
`)();
}
@@ -75,6 +93,48 @@ test('IP proxy parser ignores disabled lines and normalizes proxy entries', () =
assert.equal(pool[1].port, 8080);
});
test('IP proxy probe payload parser extracts country from common probe endpoints', () => {
const api = loadIpProxyCore();
assert.deepEqual(
api.parseProxyExitProbePayload('ip=219.104.171.52\nloc=JP\ncolo=NRT', 'text/plain'),
{ ip: '219.104.171.52', region: 'JP' }
);
assert.deepEqual(
api.parseProxyExitProbePayload(JSON.stringify({
ip: '219.104.171.52',
country: 'JP',
city: 'Osaka',
}), 'application/json'),
{ ip: '219.104.171.52', region: 'JP' }
);
assert.deepEqual(
api.parseProxyExitProbePayload(JSON.stringify({
ip: '219.104.171.52',
country_code: 'JP',
country: 'Japan',
}), 'application/json'),
{ ip: '219.104.171.52', region: 'JP' }
);
});
test('IP proxy routing state patch keeps exit probe endpoint for diagnostics', () => {
const api = loadIpProxyCore();
const patch = api.buildIpProxyRoutingStatePatch({
applied: true,
reason: 'applied',
provider: '711proxy',
exitIp: '219.104.171.52',
exitRegion: 'JP',
exitSource: 'page_context',
exitEndpoint: 'https://ipinfo.io/json',
});
assert.equal(patch.ipProxyAppliedExitIp, '219.104.171.52');
assert.equal(patch.ipProxyAppliedExitRegion, 'JP');
assert.equal(patch.ipProxyAppliedExitEndpoint, 'https://ipinfo.io/json');
});
test('711 fixed-account mode applies region and sticky session parameters', () => {
const api = loadIpProxyCore();
const pool = api.getAccountModeProxyPoolFromState({
@@ -112,6 +172,15 @@ test('IP proxy PAC keeps local traffic direct and routes target traffic through
assert.match(pac, /PROXY global\.rotgb\.711proxy\.com:10000/);
assert.match(pac, /chatgpt\.com/);
assert.match(pac, /openai\.com/);
assert.match(pac, /pm-redirects\.stripe\.com/);
assert.match(pac, /hwork\.pro/);
assert.match(pac, /auth\.openai\.com/);
assert.match(pac, /auth0\.openai\.com/);
assert.match(pac, /accounts\.openai\.com/);
assert.match(pac, /luckyous\.com/);
assert.match(pac, /forceDirectPatterns/);
assert.match(pac, /PROXY 127\.0\.0\.1:7897/);
assert.doesNotMatch(pac, /PROXY 127\.0\.0\.1:7897; DIRECT/);
});
test('sidepanel loads IP proxy scripts before sidepanel bootstrap', () => {
@@ -161,3 +230,53 @@ test('711 proxy region mismatch with missing auth challenge keeps routing as war
);
assert.match(String(status.warning || ''), /期望 US,实际 BR/);
});
test('711 sticky session keeps IP probe on ipinfo but separately checks ChatGPT target', () => {
const api = loadIpProxyCore();
assert.deepStrictEqual(
api.resolveExitProbeEndpoints({
provider: '711proxy',
username: 'USER794331-zone-custom-region-TH-session-69381850-sessTime-5',
}),
['https://ipinfo.io/json']
);
assert.deepStrictEqual(api.resolveTargetReachabilityEndpoints(), ['https://chatgpt.com/']);
});
test('target reachability failure turns detected exit IP into connectivity_failed', () => {
const api = loadIpProxyCore();
const status = api.applyTargetReachabilityExpectation({
applied: true,
reason: 'applied',
exitIp: '58.10.48.73',
exitRegion: 'TH',
}, {
reachable: false,
endpoint: 'https://chatgpt.com/',
error: 'target:page_context:https://chatgpt.com/:net::ERR_EMPTY_RESPONSE',
});
assert.equal(status.applied, false);
assert.equal(status.reason, 'connectivity_failed');
assert.match(status.error, /已检测到出口 IP 58\.10\.48\.73 \[TH\]/);
assert.match(status.error, /真实目标 chatgpt\.com 不可达/);
assert.match(status.error, /ERR_EMPTY_RESPONSE/);
});
test('connectivity_failed keeps DNR leak guard off so ChatGPT shows proxy error instead of blocked by extension', () => {
const api = loadIpProxyCore();
assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({
enabled: true,
applied: false,
reason: 'connectivity_failed',
}), false);
assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
}), true);
});
@@ -44,5 +44,6 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu
true
);
assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页');
assert.equal(loggingStatus.getLoginAuthStateLabel('add_email_page'), '添加邮箱页');
assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页');
});
+210 -50
View File
@@ -3,6 +3,11 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
const messageRouterSource = fs.readFileSync('background/message-router.js', 'utf8');
const messageRouterApi = new Function(
'self',
`${messageRouterSource}; return self.MultiPageBackgroundMessageRouter;`
)({});
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
@@ -52,6 +57,44 @@ function extractFunction(name) {
return source.slice(start, end);
}
function createLuckmailPlatformVerifyRouter({
state,
stepKeyByStep = { 10: 'platform-verify' },
} = {}) {
let clearedOptions = null;
let usedMarker = null;
const logs = [];
const router = messageRouterApi.createMessageRouter({
addLog: async (message, level) => {
logs.push({ message, level });
},
buildLocalhostCleanupPrefix: () => '',
clearLuckmailRuntimeState: async (options) => {
clearedOptions = options;
},
closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
finalizePhoneActivationAfterSuccessfulFlow: async () => {},
getCurrentLuckmailPurchase: (latestState) => latestState.currentLuckmailPurchase,
getState: async () => state,
getStepDefinitionForState: (step) => ({ id: step, key: stepKeyByStep[step] || '' }),
isHotmailProvider: () => false,
isLocalhostOAuthCallbackUrl: () => true,
isLuckmailProvider: (latestState) => latestState.mailProvider === 'luckmail-api',
patchHotmailAccount: async () => {},
setLuckmailPurchaseUsedState: async (purchaseId, used) => {
usedMarker = { purchaseId, used };
},
});
return {
router,
snapshot() {
return { clearedOptions, usedMarker, logs };
},
};
}
test('ensureLuckmailPurchaseForFlow buys openai mailbox and defaults email type to ms_graph', async () => {
const bundle = [
extractFunction('getLuckmailSessionConfig'),
@@ -451,6 +494,9 @@ const PERSISTED_SETTING_DEFAULTS = {
luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL,
luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE,
luckmailDomain: '',
luckmailUsedPurchases: {},
luckmailPreserveTagId: 0,
luckmailPreserveTagName: '保留',
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizeLuckmailBaseUrl(value) {
@@ -462,6 +508,18 @@ function normalizeLuckmailEmailType(value) {
? String(value || '').trim()
: DEFAULT_LUCKMAIL_EMAIL_TYPE;
}
function normalizeLuckmailPurchaseId(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : '';
}
function normalizeLuckmailUsedPurchases(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((result, [key, used]) => {
const normalizedKey = normalizeLuckmailPurchaseId(key);
if (normalizedKey) result[normalizedKey] = Boolean(used);
return result;
}, {});
}
function resolveLegacyAutoStepDelaySeconds() {
return undefined;
}
@@ -487,6 +545,17 @@ return {
luckmailEmailType: 'ms_imap',
luckmailDomain: 'outlook.com',
});
const statePayload = api.buildPersistentSettingsPayload({
luckmailUsedPurchases: { 88: true, 99: false, bad: true },
luckmailPreserveTagId: '9',
luckmailPreserveTagName: ' 保留邮箱 ',
});
assert.deepStrictEqual(statePayload, {
luckmailUsedPurchases: { 88: true, 99: false },
luckmailPreserveTagId: 9,
luckmailPreserveTagName: '保留邮箱',
});
});
test('listLuckmailPurchasesByProject only keeps openai purchases', async () => {
@@ -726,17 +795,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
assert.equal(snapshot.storedPayload.currentPhoneActivation, null);
});
test('handleStepData step 10 marks current LuckMail purchase as used and clears runtime state', async () => {
const bundle = extractFunction('handleStepData');
const factory = new Function(`
let clearedOptions = null;
let usedMarker = null;
const logs = [];
async function closeLocalhostCallbackTabs() {}
async function getState() {
return {
test('message router platform verify marks current LuckMail purchase as used and clears runtime state', async () => {
const { router, snapshot } = createLuckmailPlatformVerifyRouter({
state: {
mailProvider: 'luckmail-api',
currentHotmailAccountId: null,
currentLuckmailPurchase: {
@@ -744,60 +805,159 @@ async function getState() {
email_address: 'demo@outlook.com',
},
email: 'demo@outlook.com',
};
},
});
await router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = snapshot();
assert.deepStrictEqual(result.usedMarker, { purchaseId: 123, used: true });
assert.deepStrictEqual(result.clearedOptions, { clearEmail: true });
assert.equal(result.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。');
});
test('message router marks current LuckMail purchase as used on Plus platform verify step 13', async () => {
const { router, snapshot } = createLuckmailPlatformVerifyRouter({
state: {
plusModeEnabled: true,
mailProvider: 'luckmail-api',
currentHotmailAccountId: null,
currentLuckmailPurchase: {
id: 456,
email_address: 'plus@outlook.com',
},
email: 'plus@outlook.com',
},
stepKeyByStep: {
10: 'oauth-login',
13: 'platform-verify',
},
});
await router.handleStepData(10, {});
assert.equal(snapshot().usedMarker, null);
await router.handleStepData(13, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = snapshot();
assert.deepStrictEqual(result.usedMarker, { purchaseId: 456, used: true });
assert.equal(result.logs.some((entry) => /已在本地标记为已用/.test(entry.message)), true);
});
test('setLuckmailPurchaseUsedState persists used map to storage.local so reload keeps it non-reusable', async () => {
const bundle = [
extractFunction('getLuckmailUsedPurchases'),
extractFunction('setLuckmailUsedPurchasesState'),
extractFunction('setLuckmailPurchaseUsedState'),
].join('\n');
const factory = new Function(`
let sessionState = {
luckmailUsedPurchases: { 7: true },
};
let persistentUpdates = [];
let sessionUpdates = [];
let broadcasts = [];
function normalizeLuckmailPurchaseId(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : '';
}
function getCurrentLuckmailPurchase(state) {
return state.currentLuckmailPurchase;
function normalizeLuckmailUsedPurchases(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((result, [key, used]) => {
const normalizedKey = normalizeLuckmailPurchaseId(key);
if (normalizedKey) result[normalizedKey] = Boolean(used);
return result;
}, {});
}
function isHotmailProvider() {
return false;
async function getState() {
return { ...sessionState };
}
async function patchHotmailAccount() {}
function isLuckmailProvider(state) {
return state.mailProvider === 'luckmail-api';
async function setPersistentSettings(updates) {
persistentUpdates.push(updates);
}
async function setLuckmailPurchaseUsedState(purchaseId, used) {
usedMarker = { purchaseId, used };
async function setState(updates) {
sessionUpdates.push(updates);
sessionState = { ...sessionState, ...updates };
}
async function clearLuckmailRuntimeState(options) {
clearedOptions = options;
function broadcastDataUpdate(updates) {
broadcasts.push(updates);
}
async function addLog(message, level) {
logs.push({ message, level });
}
function buildLocalhostCleanupPrefix() {
return '';
}
async function closeTabsByUrlPrefix() {}
function shouldUseCustomRegistrationEmail() {
return false;
}
async function setEmailStateSilently() {}
async function setState() {}
function broadcastDataUpdate() {}
function isLocalhostOAuthCallbackUrl() {
return true;
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
${bundle}
return {
handleStepData,
setLuckmailPurchaseUsedState,
snapshot() {
return { clearedOptions, usedMarker, logs };
return { sessionState, persistentUpdates, sessionUpdates, broadcasts };
},
};
`);
const api = factory();
await api.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = await api.setLuckmailPurchaseUsedState(88, true);
const snapshot = api.snapshot();
assert.deepStrictEqual(snapshot.usedMarker, { purchaseId: 123, used: true });
assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true });
assert.equal(snapshot.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。');
assert.deepStrictEqual(result, { purchaseId: 88, used: true });
assert.deepStrictEqual(snapshot.sessionState.luckmailUsedPurchases, { 7: true, 88: true });
assert.deepStrictEqual(snapshot.persistentUpdates, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
assert.deepStrictEqual(snapshot.sessionUpdates, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
assert.deepStrictEqual(snapshot.broadcasts, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
});
test('setLuckmailPreserveTagInfo persists tag cache to storage.local', async () => {
const bundle = extractFunction('setLuckmailPreserveTagInfo');
const factory = new Function(`
let persistentUpdates = [];
let sessionUpdates = [];
let broadcasts = [];
const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留';
function normalizeLuckmailTags(tags) {
return (Array.isArray(tags) ? tags : []).map((tag) => ({
id: Number(tag?.id) || 0,
name: String(tag?.name || '').trim(),
})).filter((tag) => tag.id > 0 || tag.name);
}
async function setPersistentSettings(updates) {
persistentUpdates.push(updates);
}
async function setState(updates) {
sessionUpdates.push(updates);
}
function broadcastDataUpdate(updates) {
broadcasts.push(updates);
}
${bundle}
return {
setLuckmailPreserveTagInfo,
snapshot() {
return { persistentUpdates, sessionUpdates, broadcasts };
},
};
`);
const api = factory();
const result = await api.setLuckmailPreserveTagInfo({ id: '12', name: ' 保留邮箱 ' });
const expected = {
luckmailPreserveTagId: 12,
luckmailPreserveTagName: '保留邮箱',
};
assert.deepStrictEqual(result, expected);
assert.deepStrictEqual(api.snapshot().persistentUpdates, [expected]);
assert.deepStrictEqual(api.snapshot().sessionUpdates, [expected]);
assert.deepStrictEqual(api.snapshot().broadcasts, [expected]);
});
@@ -10,7 +10,12 @@ function createRouter(overrides = {}) {
const events = {
logs: [],
stepStatuses: [],
stateUpdates: [],
broadcasts: [],
balanceRefreshes: [],
emailStates: [],
signupPhoneStates: [],
signupPhoneSilentStates: [],
finalizePayloads: [],
phoneFinalizations: [],
notifyCompletions: [],
@@ -21,15 +26,17 @@ function createRouter(overrides = {}) {
};
const router = api.createMessageRouter({
addLog: async (message, level) => {
events.logs.push({ message, level });
addLog: async (message, level, options = {}) => {
events.logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
appendAccountRunRecord: async () => null,
batchUpdateLuckmailPurchases: async () => {},
buildLocalhostCleanupPrefix: () => '',
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: () => ({}),
broadcastDataUpdate: () => {},
broadcastDataUpdate: (updates) => {
events.broadcasts.push(updates);
},
cancelScheduledAutoRun: async () => {},
checkIcloudSession: async () => {},
clearAutoRunTimerAlarm: async () => {},
@@ -109,13 +116,21 @@ function createRouter(overrides = {}) {
events.emailStates.push(email);
},
setEmailStateSilently: async () => {},
setSignupPhoneState: async (phoneNumber) => {
events.signupPhoneStates.push(phoneNumber);
},
setSignupPhoneStateSilently: async (phoneNumber) => {
events.signupPhoneSilentStates.push(phoneNumber);
},
setIcloudAliasPreservedState: async () => {},
setIcloudAliasUsedState: async () => {},
setLuckmailPurchaseDisabledState: async () => {},
setLuckmailPurchasePreservedState: async () => {},
setLuckmailPurchaseUsedState: async () => {},
setPersistentSettings: async () => {},
setState: async () => {},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
setStepStatus: async (step, status) => {
events.stepStatuses.push({ step, status });
},
@@ -126,6 +141,10 @@ function createRouter(overrides = {}) {
testHotmailAccountMailAccess: async () => {},
upsertHotmailAccount: async () => {},
verifyHotmailAccount: async () => {},
refreshGpcCardBalance: overrides.refreshGpcCardBalance || (async (state, options) => {
events.balanceRefreshes.push({ state, options });
return { balance: '余额 3' };
}),
});
return { router, events };
@@ -143,7 +162,51 @@ test('message router skips step 3 when step 2 lands on verification page', async
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'skipped' }]);
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。');
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入验证码页,已自动跳过步骤 3。');
});
test('message router syncs signup phone runtime state from step 2 payload immediately', async () => {
const { router, events } = createRouter({
state: { stepStatuses: { 3: 'pending' } },
});
await router.handleStepData(2, {
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
});
assert.deepStrictEqual(events.emailStates, []);
assert.deepStrictEqual(events.signupPhoneSilentStates, ['66959916439']);
assert.deepStrictEqual(events.signupPhoneStates, []);
});
test('message router clears stale signup phone runtime when step 2 resolves email identity', async () => {
const { router, events } = createRouter({
state: {
stepStatuses: { 3: 'pending' },
accountIdentifierType: 'phone',
accountIdentifier: '+66959916439',
signupPhoneNumber: '+66959916439',
signupPhoneActivation: { activationId: 'old', phoneNumber: '+66959916439' },
},
});
await router.handleStepData(2, {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
});
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
assert.deepStrictEqual(events.signupPhoneSilentStates, [null]);
assert.ok(events.stateUpdates.some((updates) => (
updates.accountIdentifierType === 'email'
&& updates.accountIdentifier === 'user@example.com'
&& updates.signupPhoneNumber === ''
&& updates.signupPhoneActivation === null
&& updates.signupPhoneCompletedActivation === null
)));
});
test('message router does not overwrite a completed step 3 when step 2 is replayed', async () => {
@@ -264,7 +327,23 @@ test('message router finalizes step 3 before marking it completed', async () =>
},
},
]);
assert.deepStrictEqual(response, { ok: true });
assert.deepStrictEqual(response, { ok: true });
});
test('message router saves runtime signup phone from sidepanel message', async () => {
const { router, events } = createRouter();
const response = await router.handleMessage({
type: 'SAVE_SIGNUP_PHONE',
source: 'sidepanel',
payload: {
phoneNumber: '66959916439',
},
}, {});
assert.deepStrictEqual(events.signupPhoneStates, ['66959916439']);
assert.deepStrictEqual(events.signupPhoneSilentStates, []);
assert.deepStrictEqual(response, { ok: true, phoneNumber: '66959916439' });
});
test('message router finalizes pending phone activation on platform verify success', async () => {
@@ -352,7 +431,7 @@ test('message router marks step 3 failed when post-submit finalize fails', async
error: '步骤 3 提交后仍停留在密码页。',
},
]);
assert.equal(events.logs.some(({ message }) => /步骤 3 失败步骤 3 提交后仍停留在密码页/.test(message)), true);
assert.equal(events.logs.some(({ message, step }) => /失败步骤 3 提交后仍停留在密码页/.test(message) && step === 3), true);
assert.deepStrictEqual(response, { ok: true, error: '步骤 3 提交后仍停留在密码页。' });
});
@@ -398,3 +477,55 @@ test('message router blocks manual step 4 execution when signup page tab is miss
assert.deepStrictEqual(events.invalidations, []);
assert.deepStrictEqual(events.executedSteps, []);
});
test('message router resolves GPC OTP manual confirmation without completing step early', async () => {
const state = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'otp-request-1',
plusManualConfirmationStep: 7,
plusManualConfirmationMethod: 'gopay-otp',
};
const { router, events } = createRouter({ state });
const response = await router.handleMessage({
type: 'RESOLVE_PLUS_MANUAL_CONFIRMATION',
source: 'sidepanel',
payload: {
step: 7,
requestId: 'otp-request-1',
confirmed: true,
otp: ' 12-34 56 ',
},
}, {});
assert.deepStrictEqual(response, { ok: true });
assert.equal(events.notifyCompletions.length, 0);
assert.equal(events.stepStatuses.length, 0);
assert.equal(events.stateUpdates[0].gopayHelperResolvedOtp, '123456');
assert.equal(events.stateUpdates[0].plusManualConfirmationPending, false);
assert.deepStrictEqual(events.broadcasts[0], events.stateUpdates[0]);
});
test('message router refreshes GPC balance through explicit sidepanel message', async () => {
const state = {
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'http://localhost:18473/',
gopayHelperCardKey: 'state_card',
};
const { router, events } = createRouter({ state });
const response = await router.handleMessage({
type: 'REFRESH_GPC_CARD_BALANCE',
source: 'sidepanel',
payload: {
gopayHelperCardKey: 'payload_card',
reason: 'manual',
},
}, {});
assert.deepStrictEqual(response, { ok: true, balance: '余额 3' });
assert.equal(events.balanceRefreshes.length, 1);
assert.equal(events.balanceRefreshes[0].state.gopayHelperApiUrl, 'http://localhost:18473/');
assert.equal(events.balanceRefreshes[0].state.gopayHelperCardKey, 'payload_card');
assert.deepStrictEqual(events.balanceRefreshes[0].options, { reason: 'manual' });
});
@@ -16,6 +16,21 @@ test('navigation utils module exposes a factory', () => {
assert.equal(typeof api?.createNavigationUtils, 'function');
});
test('navigation utils recognize signup password pages for email and phone signup', () => {
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
const utils = api.createNavigationUtils({
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
normalizeLocalCpaStep9Mode: (value) => value,
});
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/create-account/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in'), false);
});
test('navigation utils treat 126 mail hosts as part of the shared NetEase mail family', () => {
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
const globalScope = {};
@@ -30,8 +30,8 @@ test('platform verify module supports codex2api protocol callback exchange', asy
const completed = [];
const logs = [];
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
chrome: {},
closeConflictingTabsForSource: async () => {},
@@ -63,8 +63,8 @@ test('platform verify module supports codex2api protocol callback exchange', asy
});
assert.deepStrictEqual(logs, [
{ message: '步骤 10正在向 Codex2API 提交回调并创建账号...', level: 'info' },
{ message: '步骤 10OAuth 账号 flow@example.com 添加成功', level: 'ok' },
{ message: '正在向 Codex2API 提交回调并创建账号...', level: 'info', step: 10, stepKey: 'platform-verify' },
{ message: 'OAuth 账号 flow@example.com 添加成功', level: 'ok', step: 10, stepKey: 'platform-verify' },
]);
assert.deepStrictEqual(completed, [
{
@@ -8,8 +8,8 @@ function createDeps(overrides = {}) {
const uiCalls = [];
const deps = {
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
chrome: {
tabs: {
@@ -91,8 +91,8 @@ test('platform verify module submits CPA callback via management API first', asy
},
]);
assert.deepStrictEqual(logs, [
{ message: '步骤 10正在通过 CPA 管理接口提交回调地址...', level: 'info' },
{ message: '步骤 10CPA API 回调提交成功', level: 'ok' },
{ message: '正在通过 CPA 管理接口提交回调地址...', level: 'info', step: 10, stepKey: 'platform-verify' },
{ message: 'CPA API 回调提交成功', level: 'ok', step: 10, stepKey: 'platform-verify' },
]);
} finally {
globalThis.fetch = originalFetch;
@@ -160,8 +160,10 @@ test('platform verify module fails fast when CPA API submit fails', async () =>
assert.equal(uiCalls.length, 0);
assert.equal(completed.length, 0);
assert.equal(logs[0].message, '步骤 10正在通过 CPA 管理接口提交回调地址...');
assert.match(logs[1].message, /步骤 10CPA 接口提交失败:failed to persist oauth callback/);
assert.equal(logs[0].message, '正在通过 CPA 管理接口提交回调地址...');
assert.equal(logs[0].step, 10);
assert.match(logs[1].message, /CPA 接口提交失败:failed to persist oauth callback/);
assert.equal(logs[1].step, 10);
assert.equal(logs[1].level, 'error');
} finally {
globalThis.fetch = originalFetch;
@@ -237,3 +239,36 @@ test('platform verify module rejects callback when cpa oauth state mismatches',
globalThis.fetch = originalFetch;
}
});
test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const sentMessages = [];
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
isTabAlive: async () => true,
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return { ok: true };
},
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'sub2api',
visibleStep: 13,
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.step, 13);
});
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.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);
}
test('markCurrentRegistrationAccountUsed uses fresh state when checkout passes stale state', async () => {
const bundle = extractFunction('markCurrentRegistrationAccountUsed');
const factory = new Function(`
const patchCalls = [];
const logs = [];
async function getState() {
return {
mailProvider: 'hotmail',
currentHotmailAccountId: 'hot-1',
email: 'fresh@example.com',
};
}
function isHotmailProvider(state) {
return String(state.mailProvider || '').toLowerCase() === 'hotmail';
}
function isLuckmailProvider() {
return false;
}
function getCurrentLuckmailPurchase() {
return null;
}
async function patchHotmailAccount(id, updates) {
patchCalls.push({ id, updates });
}
async function setLuckmailPurchaseUsedState() {}
async function clearLuckmailRuntimeState() {}
async function patchMail2925Account() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {
return { handled: false };
}
async function markCurrentCustomEmailPoolEntryUsed() {
return { updated: false };
}
async function addLog(message, level) {
logs.push({ message, level });
}
${bundle}
return { markCurrentRegistrationAccountUsed, patchCalls, logs };
`);
const api = factory();
const result = await api.markCurrentRegistrationAccountUsed({ email: 'stale@example.com' }, {
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
});
assert.equal(result.updated, true);
assert.equal(api.patchCalls.length, 1);
assert.equal(api.patchCalls[0].id, 'hot-1');
assert.equal(api.patchCalls[0].updates.used, true);
assert.equal(api.logs.some((entry) => /Hotmail 账号已标记为已用/.test(entry.message)), true);
});
@@ -0,0 +1,140 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.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;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
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);
}
test('signup method resolution freezes per run and falls back when phone signup is unavailable', async () => {
const api = new Function(`
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const logs = [];
let state = {
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: false,
contributionMode: false,
resolvedSignupMethod: null,
};
async function getState() { return { ...state }; }
async function setState(updates) { state = { ...state, ...updates }; }
async function addLog(message, level = 'info') { logs.push({ message, level }); }
${extractFunction('normalizeSignupMethod')}
${extractFunction('canUsePhoneSignup')}
${extractFunction('resolveSignupMethod')}
${extractFunction('ensureResolvedSignupMethodForRun')}
return {
logs,
get state() { return state; },
setState,
resolveSignupMethod,
ensureResolvedSignupMethodForRun,
};
`)();
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true }), 'phone');
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: false }), 'email');
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true, plusModeEnabled: true }), 'email');
assert.equal(api.resolveSignupMethod({ signupMethod: 'email', resolvedSignupMethod: 'phone', phoneVerificationEnabled: false }), 'phone');
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
assert.equal(api.state.resolvedSignupMethod, 'phone');
await api.setState({ signupMethod: 'email', phoneVerificationEnabled: false });
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
assert.equal(api.state.resolvedSignupMethod, 'phone');
await api.setState({ resolvedSignupMethod: null, signupMethod: 'phone', phoneVerificationEnabled: false });
assert.equal(await api.ensureResolvedSignupMethodForRun({ force: true }), 'email');
assert.equal(api.state.resolvedSignupMethod, 'email');
assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true);
});
test('background step definitions resolve titles from the frozen signup method', () => {
const api = new Function(`
const captured = [];
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const self = {
MultiPageStepDefinitions: {
getSteps(options) {
captured.push(options);
return [{
id: 2,
key: 'submit-signup-email',
title: options.signupMethod === 'phone' ? '注册并输入手机号' : '注册并输入邮箱',
}];
},
},
};
${extractFunction('isPlusModeState')}
${extractFunction('normalizePlusPaymentMethod')}
${extractFunction('normalizeSignupMethod')}
${extractFunction('getSignupMethodForStepDefinitions')}
${extractFunction('getStepDefinitionsForState')}
return {
getCaptured: () => captured.slice(),
getStepDefinitionsForState,
};
`)();
const steps = api.getStepDefinitionsForState({
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
signupMethod: 'email',
resolvedSignupMethod: 'phone',
});
assert.deepEqual(api.getCaptured(), [{
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
signupMethod: 'phone',
}]);
assert.equal(steps[0].title, '注册并输入手机号');
});
@@ -10,6 +10,10 @@ const signupFlowSource = fs.readFileSync('background/signup-flow-helpers.js', 'u
const signupFlowGlobalScope = {};
const signupFlowApi = new Function('self', `${signupFlowSource}; return self.MultiPageSignupFlowHelpers;`)(signupFlowGlobalScope);
const navigationSource = fs.readFileSync('background/navigation-utils.js', 'utf8');
const navigationGlobalScope = {};
const navigationApi = new Function('self', `${navigationSource}; return self.MultiPageBackgroundNavigationUtils;`)(navigationGlobalScope);
test('step 2 completes with password step skipped when landing on email verification page', async () => {
const completedPayloads = [];
@@ -39,6 +43,8 @@ test('step 2 completes with password step skipped when landing on email verifica
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'verification_page',
nextSignupUrl: 'https://auth.openai.com/email-verification',
skippedPasswordStep: true,
@@ -76,6 +82,8 @@ test('step 2 keeps password flow when landing on password page', async () => {
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'password_page',
nextSignupUrl: 'https://auth.openai.com/create-account/password',
skippedPasswordStep: false,
@@ -84,6 +92,238 @@ test('step 2 keeps password flow when landing on password page', async () => {
]);
});
test('step 2 uses phone activation when resolved signup method is phone', async () => {
const completedPayloads = [];
const sequence = [];
const sentPayloads = [];
const activation = {
activationId: 'signup-activation',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 0,
maxUses: 3,
};
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 14 }),
ensureSignupPostEmailPageReadyInTab: async () => {
throw new Error('email landing helper should not be used for phone signup');
},
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 14,
isTabAlive: async () => true,
phoneVerificationHelpers: {
prepareSignupPhoneActivation: async () => {
sequence.push('prepareSignupPhoneActivation');
return activation;
},
cancelSignupPhoneActivation: async () => {
throw new Error('activation should not be cancelled on success');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
sequence.push('ensureSignupPhoneEntryReady');
return {
ready: true,
state: 'phone_entry',
url: 'https://chatgpt.com/',
};
}
sequence.push('submitSignupPhone');
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({ signupMethod: 'phone' });
assert.deepStrictEqual(sequence, [
'ensureSignupPhoneEntryReady',
'prepareSignupPhoneActivation',
'submitSignupPhone',
]);
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneActivation: activation,
nextSignupState: 'phone_verification_page',
nextSignupUrl: 'https://auth.openai.com/phone-verification',
skippedPasswordStep: true,
},
},
]);
});
test('step 2 reuses existing signup phone activation without acquiring a new number', async () => {
const completedPayloads = [];
const sequence = [];
const sentPayloads = [];
const activation = {
activationId: 'existing-signup-activation',
phoneNumber: '+446700000001',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
countryLabel: 'United Kingdom',
};
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 15 }),
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 15,
isTabAlive: async () => true,
phoneVerificationHelpers: {
normalizeActivation: (record) => record,
prepareSignupPhoneActivation: async () => {
throw new Error('prepareSignupPhoneActivation should not run when signup activation already exists');
},
cancelSignupPhoneActivation: async () => {
throw new Error('activation should not be cancelled on success');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
sequence.push('ensureSignupPhoneEntryReady');
return { ready: true, state: 'phone_entry' };
}
sequence.push('submitSignupPhone');
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({
signupMethod: 'phone',
signupPhoneActivation: activation,
});
assert.deepStrictEqual(sequence, [
'ensureSignupPhoneEntryReady',
'submitSignupPhone',
]);
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '+446700000001',
countryId: 16,
countryLabel: 'United Kingdom',
},
]);
assert.equal(completedPayloads[0].payload.signupPhoneActivation, activation);
});
test('step 2 submits manual signup phone without acquiring a number', async () => {
const completedPayloads = [];
const sentPayloads = [];
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 16 }),
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 16,
isTabAlive: async () => true,
phoneVerificationHelpers: {
prepareSignupPhoneActivation: async () => {
throw new Error('prepareSignupPhoneActivation should not run for manual signup phone');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
return { ready: true, state: 'phone_entry' };
}
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({
signupMethod: 'phone',
signupPhoneNumber: '+446700000002',
accountIdentifierType: 'phone',
accountIdentifier: '+446700000002',
});
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '+446700000002',
countryId: null,
countryLabel: '',
},
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '+446700000002',
signupPhoneNumber: '+446700000002',
signupPhoneActivation: null,
nextSignupState: 'phone_verification_page',
nextSignupUrl: 'https://auth.openai.com/phone-verification',
skippedPasswordStep: true,
},
},
]);
});
test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on chatgpt home', async () => {
const completedPayloads = [];
const logs = [];
@@ -126,6 +366,68 @@ test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on
assert.ok(logs.some((item) => /3\/4\/5/.test(item.message)));
});
test('step 2 does not force auth-entry retry on logged-out chatgpt home when content reports entry_home', async () => {
const completedPayloads = [];
const logs = [];
const sentPayloads = [];
let authEntryCalls = 0;
const executor = step2Api.createStep2Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
get: async () => ({ url: 'https://chatgpt.com/' }),
},
},
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupAuthEntryPageReady: async () => {
authEntryCalls += 1;
return { tabId: 15 };
},
ensureSignupEntryPageReady: async () => ({ tabId: 15 }),
ensureSignupPostEmailPageReadyInTab: async () => ({
state: 'password_page',
url: 'https://auth.openai.com/create-account/password',
}),
getTabId: async () => 15,
isTabAlive: async () => true,
resolveSignupEmailForFlow: async () => 'user@example.com',
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_ENTRY_READY') {
return { ready: true, state: 'entry_home', url: 'https://chatgpt.com/' };
}
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({ email: 'user@example.com' });
assert.equal(authEntryCalls, 0);
assert.deepStrictEqual(sentPayloads, [{ email: 'user@example.com' }]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'password_page',
nextSignupUrl: 'https://auth.openai.com/create-account/password',
skippedPasswordStep: false,
},
},
]);
assert.equal(logs.some((item) => /已登录 ChatGPT 首页/.test(item.message)), false);
});
test('signup flow helper recognizes email verification page as post-email landing page', async () => {
let ensureCalls = 0;
let passwordReadyChecks = 0;
@@ -175,6 +477,69 @@ test('signup flow helper recognizes email verification page as post-email landin
assert.equal(passwordReadyChecks, 0);
});
test('signup flow helper accepts phone signup landing on login password page', async () => {
let ensureCalls = 0;
let passwordReadyChecks = 0;
let predicateAcceptedLoginPassword = false;
const navigationUtils = navigationApi.createNavigationUtils({
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
normalizeLocalCpaStep9Mode: (value) => value,
});
const helpers = signupFlowApi.createSignupFlowHelpers({
buildGeneratedAliasEmail: () => '',
chrome: {
tabs: {
get: async () => ({
id: 22,
url: 'https://auth.openai.com/log-in/password',
}),
},
},
ensureContentScriptReadyOnTab: async () => {
ensureCalls += 1;
},
ensureHotmailAccountForFlow: async () => ({}),
ensureLuckmailPurchaseForFlow: async () => ({}),
isGeneratedAliasProvider: () => false,
isHotmailProvider: () => false,
isLuckmailProvider: () => false,
isSignupEmailVerificationPageUrl: navigationUtils.isSignupEmailVerificationPageUrl,
isSignupPasswordPageUrl: (url) => {
const accepted = navigationUtils.isSignupPasswordPageUrl(url);
if (accepted && /\/log-in\/password(?:[/?#]|$)/i.test(url || '')) {
predicateAcceptedLoginPassword = true;
}
return accepted;
},
reuseOrCreateTab: async () => 22,
sendToContentScriptResilient: async (_source, message) => {
assert.equal(message.type, 'ENSURE_SIGNUP_PASSWORD_PAGE_READY');
passwordReadyChecks += 1;
return {};
},
setEmailState: async () => {},
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
SIGNUP_PAGE_INJECT_FILES: [],
waitForTabUrlMatch: async (_tabId, predicate) => {
const url = 'https://auth.openai.com/log-in/password';
return predicate(url) ? { id: 22, url } : null;
},
});
const result = await helpers.ensureSignupPostIdentityPageReadyInTab(22, 2);
assert.deepStrictEqual(result, {
ready: true,
state: 'password_page',
url: 'https://auth.openai.com/log-in/password',
});
assert.equal(predicateAcceptedLoginPassword, true);
assert.equal(ensureCalls, 1);
assert.equal(passwordReadyChecks, 1);
});
test('signup flow helper reuses existing managed alias email when it is still compatible', async () => {
let buildCalls = 0;
let setEmailCalls = 0;
@@ -214,6 +579,73 @@ test('signup flow helper reuses existing managed alias email when it is still co
assert.equal(setEmailCalls, 0);
});
test('signup flow helper can generate an email on demand when add-email starts from phone signup', async () => {
const fetchedStates = [];
const setStateCalls = [];
const helpers = signupFlowApi.createSignupFlowHelpers({
buildGeneratedAliasEmail: () => '',
chrome: { tabs: { get: async () => ({ id: 21, url: 'https://auth.openai.com/create-account/password' }) } },
ensureContentScriptReadyOnTab: async () => {},
ensureHotmailAccountForFlow: async () => ({}),
ensureLuckmailPurchaseForFlow: async () => ({}),
fetchGeneratedEmail: async (state, options) => {
fetchedStates.push({ state, options });
return 'duck.generated@example.com';
},
isGeneratedAliasProvider: () => false,
isReusableGeneratedAliasEmail: () => false,
isHotmailProvider: () => false,
isLuckmailProvider: () => false,
isSignupEmailVerificationPageUrl: () => false,
isSignupPasswordPageUrl: () => true,
reuseOrCreateTab: async () => 21,
sendToContentScriptResilient: async () => ({}),
setEmailState: async () => {
throw new Error('fetchGeneratedEmail already persists the generated email');
},
setState: async (updates) => {
setStateCalls.push(updates);
},
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
SIGNUP_PAGE_INJECT_FILES: [],
waitForTabUrlMatch: async () => null,
});
const email = await helpers.resolveSignupEmailForFlow({
email: '',
emailGenerator: 'duck',
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
signupPhoneCompletedActivation: {
activationId: 'signup-completed',
phoneNumber: '+447780579093',
},
}, {
preserveAccountIdentity: true,
});
assert.equal(email, 'duck.generated@example.com');
assert.equal(fetchedStates.length, 1);
assert.equal(fetchedStates[0].options.preserveAccountIdentity, true);
assert.deepStrictEqual(setStateCalls, [
{
email: 'duck.generated@example.com',
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
signupPhoneActivation: null,
signupPhoneCompletedActivation: {
activationId: 'signup-completed',
phoneNumber: '+447780579093',
},
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
},
]);
});
test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
let ensureCalls = 0;
const messages = [];
+15
View File
@@ -8,9 +8,24 @@ test('background imports step registry and shared step definitions', () => {
assert.match(source, /data\/step-definitions\.js/);
assert.match(source, /MultiPageStepDefinitions\?\.getSteps/);
assert.match(source, /getStepRegistryForState\(state\)/);
assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/);
assert.match(source, /plusPayPalStepRegistry/);
assert.match(source, /plusGoPayStepRegistry/);
assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/);
assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/);
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/gopay-manual-confirm\.js/);
assert.match(source, /'gopay-subscription-confirm': \(state\) => goPayManualConfirmExecutor\.executeGoPayManualConfirm\(state\)/);
assert.match(source, /background\/steps\/paypal-approve\.js/);
assert.match(source, /background\/steps\/gopay-approve\.js/);
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
});
test('GoPay approve executor receives debugger click and manual OTP helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /createGoPayApproveExecutor\(\{[\s\S]*clickWithDebugger[\s\S]*requestGoPayOtpInput[\s\S]*\}\)/);
assert.match(source, /REQUEST_GOPAY_OTP_INPUT/);
});
@@ -44,8 +44,159 @@ test('step 3 reuses existing generated password when rerunning the same email fl
source: 'background',
payload: {
email: 'keep@example.com',
phoneNumber: '',
accountIdentifierType: 'email',
accountIdentifier: 'keep@example.com',
password: 'Secret123!',
},
},
]);
});
test('step 3 supports phone-only signup identity when password page is present', async () => {
const events = {
passwordStates: [],
messages: [],
stateUpdates: [],
logs: [],
};
const executor = api.createStep3Executor({
addLog: async (message) => {
events.logs.push(message);
},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async (password) => {
events.passwordStates.push(password);
},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep3({
email: '',
signupPhoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
customPassword: 'PhoneSecret123!',
accounts: [],
});
assert.deepStrictEqual(events.passwordStates, ['PhoneSecret123!']);
assert.equal(events.logs.some((message) => /注册手机号为 66959916439/.test(message)), true);
assert.equal(events.stateUpdates.length, 1);
assert.deepStrictEqual(events.stateUpdates[0].accounts.map((account) => ({
email: account.email,
phoneNumber: account.phoneNumber,
accountIdentifierType: account.accountIdentifierType,
accountIdentifier: account.accountIdentifier,
})), [
{
email: '',
phoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
},
]);
assert.deepStrictEqual(events.messages, [
{
type: 'EXECUTE_STEP',
step: 3,
source: 'background',
payload: {
email: '',
phoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
password: 'PhoneSecret123!',
},
},
]);
});
test('step 3 phone signup intent does not fall back to a stale email identity', async () => {
const events = {
passwordStates: [],
messages: [],
stateUpdates: [],
};
const executor = api.createStep3Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
resolveSignupMethod: () => 'phone',
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async (password) => {
events.passwordStates.push(password);
},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await assert.rejects(
() => executor.executeStep3({
email: 'stale@example.com',
signupMethod: 'phone',
resolvedSignupMethod: 'phone',
accountIdentifierType: null,
accountIdentifier: '',
customPassword: 'PhoneSecret123!',
accounts: [],
}),
/缺少注册手机号,请先完成步骤 2 或在侧栏填写注册手机号后再执行步骤 3。/
);
assert.deepStrictEqual(events.passwordStates, []);
assert.deepStrictEqual(events.stateUpdates, []);
assert.deepStrictEqual(events.messages, []);
});
test('step 3 respects resolved email fallback when phone signup is unavailable', async () => {
const events = {
messages: [],
};
const executor = api.createStep3Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
resolveSignupMethod: () => 'email',
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async () => {},
setState: async () => {},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep3({
email: 'fallback@example.com',
signupMethod: 'phone',
resolvedSignupMethod: 'email',
customPassword: 'EmailSecret123!',
accounts: [],
});
assert.equal(events.messages[0].payload.accountIdentifierType, 'email');
assert.equal(events.messages[0].payload.accountIdentifier, 'fallback@example.com');
});
@@ -231,6 +231,82 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
assert.equal(resolveCalls, 0);
});
test('step 4 phone signup branch uses SMS helper and does not poll mailbox', async () => {
const completions = [];
const phoneCalls = [];
let getMailConfigCalls = 0;
let resolveCalls = 0;
const executor = api.createStep4Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
completeStepFromBackground: async (step, payload) => {
completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => {
getMailConfigCalls += 1;
throw new Error('mail config should not be required for phone signup');
},
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
phoneVerificationHelpers: {
completeSignupPhoneVerificationFlow: async (tabId, options) => {
phoneCalls.push({ tabId, options });
return {
success: true,
skipProfileStep: true,
skipProfileStepReason: 'combined_verification_profile',
};
},
},
resolveSignupMethod: () => 'phone',
resolveVerificationStep: async () => {
resolveCalls += 1;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async () => ({ ready: true }),
sendToContentScriptResilient: async () => ({ ready: true }),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
});
await executor.executeStep4({
resolvedSignupMethod: 'phone',
accountIdentifierType: 'phone',
signupPhoneNumber: '66959916439',
signupPhoneActivation: { activationId: 'signup-123', phoneNumber: '66959916439' },
});
assert.equal(getMailConfigCalls, 0);
assert.equal(resolveCalls, 0);
assert.equal(phoneCalls.length, 1);
assert.equal(phoneCalls[0].tabId, 1);
assert.equal(phoneCalls[0].options.state.accountIdentifierType, 'phone');
assert.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true);
assert.deepStrictEqual(completions, [
{
step: 4,
payload: {
phoneVerification: true,
code: '',
skipProfileStep: true,
skipProfileStepReason: 'combined_verification_profile',
},
},
]);
});
test('step 4 prepare retries transport by recovering retry page without replaying full prepare loop', async () => {
let sendToContentScriptCalls = 0;
let recoverCalls = 0;
+129
View File
@@ -227,6 +227,135 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
]);
});
test('step 7 forwards phone login identity payload when account identifier is phone', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
payloads: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
password: 'secret',
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.payloads.push(message.payload);
return {
step6Outcome: 'success',
state: 'phone_verification_page',
loginVerificationRequestedAt: 123456,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
password: 'secret',
});
assert.deepStrictEqual(events.payloads, [
{
email: '',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
accountIdentifier: '66959916439',
loginIdentifierType: 'phone',
password: 'secret',
visibleStep: 7,
},
]);
});
test('step 7 can start from a manually filled signup phone without completed step 2 or step 3 state', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
payloads: [],
completions: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
stepStatuses: { 2: 'pending', 3: 'pending' },
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.payloads.push(message.payload);
return {
step6Outcome: 'success',
state: 'phone_verification_page',
loginVerificationRequestedAt: 987654,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
stepStatuses: { 2: 'pending', 3: 'pending' },
});
assert.equal(events.payloads[0].loginIdentifierType, 'phone');
assert.equal(events.payloads[0].phoneNumber, '+447780579093');
assert.equal(events.payloads[0].email, '');
assert.equal(events.payloads[0].password, '');
assert.deepStrictEqual(events.completions, [
{
step: 7,
payload: {
loginVerificationRequestedAt: 987654,
},
},
]);
});
test('step 7 stops immediately when management secret is missing', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
+270 -2
View File
@@ -85,11 +85,273 @@ test('step 8 submits login verification directly without replaying step 7', asyn
{ step8VerificationTargetEmail: 'display.user@example.com' },
]);
assert.deepStrictEqual(calls.ensureReadyOptions, [
{ visibleStep: 8, authLoginStep: 7, timeoutMs: 5000 },
{
visibleStep: 8,
authLoginStep: 7,
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: 5000,
},
]);
assert.equal(calls.resolveOptions.completionStep, 8);
});
test('step 8 keeps phone-registered accounts on email-code flow when page is email verification', async () => {
const calls = {
getMailConfigCalls: 0,
helperCalls: [],
completions: [],
resolveCalls: 0,
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'phone-user@example.com' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: () => {
calls.getMailConfigCalls += 1;
return {
provider: 'qq',
label: 'QQ 邮箱',
};
},
getState: async () => ({
accountIdentifierType: 'phone',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
},
}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
phoneVerificationHelpers: {
completeLoginPhoneVerificationFlow: async (tabId, options) => {
calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state });
return { code: '654321' };
},
},
resolveSignupMethod: () => 'phone',
resolveVerificationStep: async () => {
calls.resolveCalls += 1;
},
rerunStep7ForStep8Recovery: async () => {
throw new Error('phone login branch should not rerun step 7 in this test');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
},
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.getMailConfigCalls, 1);
assert.equal(calls.resolveCalls, 1);
assert.deepStrictEqual(calls.helperCalls, []);
assert.deepStrictEqual(calls.completions, []);
});
test('step 8 routes only a real phone verification page through sms helper', async () => {
const calls = {
getMailConfigCalls: 0,
helperCalls: [],
completions: [],
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'phone_verification_page' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: () => {
calls.getMailConfigCalls += 1;
return {
provider: 'qq',
label: 'QQ 邮箱',
};
},
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
phoneVerificationHelpers: {
completeLoginPhoneVerificationFlow: async (tabId, options) => {
calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state });
return { code: '654321' };
},
},
resolveVerificationStep: async () => {
throw new Error('real phone verification branch should not call email verification flow');
},
rerunStep7ForStep8Recovery: async () => {
throw new Error('real phone verification branch should not rerun step 7 in this test');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.getMailConfigCalls, 0);
assert.deepStrictEqual(calls.helperCalls, [
{
tabId: 1,
visibleStep: 8,
state: {
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
},
},
]);
assert.deepStrictEqual(calls.completions, [
{
step: 8,
payload: {
phoneVerification: true,
loginPhoneVerification: true,
code: '654321',
},
},
]);
});
test('step 8 submits add-email before polling the email verification code', async () => {
const calls = {
contentMessages: [],
resolvedStates: [],
setStates: [],
mailStates: [],
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'add_email_page', url: 'https://auth.openai.com/add-email' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: (state) => {
calls.mailStates.push(state);
return {
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
};
},
getState: async () => ({
email: '',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
}),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveSignupEmailForFlow: async (state, options = {}) => {
calls.resolvedStates.push(state);
calls.resolveOptions = options;
return 'new.user@example.com';
},
resolveVerificationStep: async (_step, state, _mail, options) => {
calls.resolvedVerification = { state, options };
},
rerunStep7ForStep8Recovery: async () => {},
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_source, message) => {
calls.contentMessages.push(message);
assert.equal(message.type, 'SUBMIT_ADD_EMAIL');
assert.equal(message.payload.email, 'new.user@example.com');
return {
submitted: true,
displayedEmail: 'new.user@example.com',
url: 'https://auth.openai.com/email-verification',
};
},
setState: async (payload) => {
calls.setStates.push(payload);
},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.contentMessages.length, 1);
assert.equal(calls.resolvedStates.length, 1);
assert.equal(calls.resolveOptions.preserveAccountIdentity, true);
assert.equal(calls.mailStates[0].email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.state.email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.options.targetEmail, 'new.user@example.com');
assert.deepStrictEqual(calls.setStates, [
{
email: 'new.user@example.com',
step8VerificationTargetEmail: 'new.user@example.com',
},
{
step8VerificationTargetEmail: 'new.user@example.com',
},
]);
});
test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => {
let resolvedStep = null;
let resolvedOptions = null;
@@ -155,7 +417,13 @@ test('Plus login-code step reuses step 8 verification logic but completes visibl
assert.equal(resolvedStep, 8);
assert.equal(resolvedOptions.completionStep, 11);
assert.equal(resolvedOptions.targetEmail, 'plus.user@example.com');
assert.deepStrictEqual(readyOptions, { visibleStep: 11, authLoginStep: 10, timeoutMs: 9000 });
assert.deepStrictEqual(readyOptions, {
visibleStep: 11,
authLoginStep: 10,
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: 9000,
});
assert.deepStrictEqual(remainingStepCalls, [11, 11]);
});
+290
View File
@@ -0,0 +1,290 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const api = new Function('self', `${source}; return self.PhoneSmsFiveSimProvider;`)({});
function createTextResponse(payload, ok = true, status = ok ? 200 : 400) {
return {
ok,
status,
text: async () => (typeof payload === 'string' ? payload : JSON.stringify(payload)),
};
}
test('5sim provider fetches profile balance with bearer token', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
requests.push({ url: new URL(url), options });
return createTextResponse({ balance: 123.45, frozen_balance: 6.7, rating: 99 });
},
});
const balance = await provider.fetchBalance({ fiveSimApiKey: 'demo-key' });
assert.equal(requests[0].url.pathname, '/v1/user/profile');
assert.equal(requests[0].options.headers.Authorization, 'Bearer demo-key');
assert.equal(balance.balance, 123.45);
assert.equal(balance.frozenBalance, 6.7);
});
test('5sim provider maps countries and prices', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/countries') {
return createTextResponse({
england: { text_en: 'England', iso: { GB: 1 }, prefix: { 44: 1 } },
indonesia: { text_en: 'Indonesia', iso: { ID: 1 }, prefix: { '+62': 1 } },
thailand: { text_en: 'Thailand', iso: { TH: 1 }, prefix: { '+66': 1 } },
vietnam: { text_en: 'Vietnam', iso: { VN: 1 }, prefix: { '+84': 1 } },
});
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { any: { openai: { cost: 10, count: 2 } } } });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
});
const countries = await provider.fetchCountries({});
const prices = await provider.fetchPrices({}, { id: 'vietnam', label: 'Vietnam' });
const entries = provider.collectPriceEntries(prices, []);
assert.equal(countries.length, 4);
assert.equal(countries.some((country) => country.id === 'england'), true);
assert.equal(countries.some((country) => country.id === 'indonesia'), true);
assert.deepStrictEqual(
countries.find((country) => country.id === 'vietnam'),
{
id: 'vietnam',
label: '越南 (Vietnam)',
searchText: 'vietnam 越南 (Vietnam) Vietnam VN +84',
}
);
assert.equal(requests[1].url.searchParams.get('country'), 'vietnam');
assert.equal(requests[1].url.searchParams.get('product'), 'openai');
assert.deepStrictEqual(entries, [{ cost: 10, count: 2, inStock: true }]);
});
test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 4, Price: 8 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { any: { openai: { cost: 9.5, count: 4 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ id: 1001, phone: '+84901123456', country: 'vietnam', operator: 'any', status: 'PENDING' });
}
if (parsed.pathname === '/v1/user/check/1001') {
return createTextResponse({ id: 1001, phone: '+447911123456', status: 'RECEIVED', sms: [{ text: 'code 112233' }] });
}
if (parsed.pathname === '/v1/user/finish/1001') return createTextResponse({ status: 'FINISHED' });
if (parsed.pathname === '/v1/user/cancel/1001') return createTextResponse({ status: 'CANCELED' });
if (parsed.pathname === '/v1/user/ban/1001') return createTextResponse({ status: 'BANNED' });
if (parsed.pathname === '/v1/user/reuse/openai/84901123456') {
return createTextResponse({ id: 1002, phone: '+84901123456', country: 'vietnam', status: 'PENDING' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const state = { fiveSimApiKey: 'demo-key', fiveSimCountryId: 'vietnam', fiveSimCountryLabel: '越南 (Vietnam)', fiveSimMaxPrice: '12', fiveSimOperator: 'any' };
const activation = await provider.requestActivation(state);
const code = await provider.pollActivationCode(state, activation, { timeoutMs: 1000, intervalMs: 1, maxRounds: 1 });
await provider.finishActivation(state, activation);
await provider.cancelActivation(state, activation);
await provider.banActivation(state, activation);
const reused = await provider.reuseActivation(state, activation);
assert.equal(activation.provider, '5sim');
assert.equal(activation.activationId, '1001');
assert.equal(activation.countryId, 'vietnam');
assert.equal(code, '112233');
assert.equal(reused.activationId, '1002');
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '12');
assert.equal(buy.url.searchParams.get('reuse'), '1');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
'/v1/user/check/1001',
'/v1/user/finish/1001',
'/v1/user/cancel/1001',
'/v1/user/ban/1001',
'/v1/user/reuse/openai/84901123456',
]
);
});
test('5sim provider prefers buy-compatible products price over operator detail price', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 4609, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({
vietnam: {
openai: {
virtual21: { cost: 0.0769, count: 0 },
virtual47: { cost: 0.1282, count: 4608 },
},
},
});
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ id: 2001, phone: '+84901234567', country: 'vietnam', operator: 'any', status: 'PENDING' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
});
assert.equal(activation.activationId, '2001');
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '0.08');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
]
);
});
test('5sim provider rejects maxPrice with custom operator before buying', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url) => {
requests.push(url);
throw new Error(`unexpected request ${url}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '瓒婂崡 (Vietnam)',
fiveSimMaxPrice: '12',
fiveSimOperator: 'virtual21',
}),
/maxPrice only works when operator is "any"/
);
assert.deepStrictEqual(requests, []);
});
test('5sim provider reports raw buy payload when HTTP 200 response has no activation', async () => {
const provider = api.createProvider({
fetchImpl: async (url) => {
const parsed = new URL(url);
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { openai: { virtual47: { cost: 0.1282, count: 10 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ status: 'no free phones', detail: 'operator unavailable' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
}),
/越南 \(Vietnam\): no free phones/
);
});
test('5sim provider reports purchase rate limit separately from no-number countries', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url) => {
const parsed = new URL(url);
requests.push(parsed.pathname);
if (parsed.pathname === '/v1/guest/products/thailand/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.1 } });
}
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
const country = parsed.searchParams.get('country');
return createTextResponse({ [country]: { any: { openai: { cost: country === 'vietnam' ? 0.08 : 0.1, count: 10 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/thailand/any/openai') {
return createTextResponse({ status: 'rate limit' });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ status: 'rate limit' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'thailand',
fiveSimCountryLabel: '泰国 (Thailand)',
fiveSimCountryFallback: [{ id: 'vietnam', label: '越南 (Vietnam)' }],
fiveSimOperator: 'any',
}),
(error) => {
assert.match(error.message, /^FIVE_SIM_RATE_LIMIT::/);
assert.match(error.message, /5sim 购买接口触发限流/);
assert.match(error.message, /泰国 \(Thailand\): rate limit/);
assert.match(error.message, /越南 \(Vietnam\): rate limit/);
assert.doesNotMatch(error.message, /均无可用号码/);
return true;
}
);
assert.deepStrictEqual(
requests.filter((pathname) => pathname.includes('/buy/activation')),
[
'/v1/user/buy/activation/thailand/any/openai',
'/v1/user/buy/activation/vietnam/any/openai',
]
);
});
+138
View File
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/gopay-approve.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 index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
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);
}
test('GoPay OTP always requests manual confirmation even when a previous code exists', () => {
const body = extractFunction('requestManualGoPayOtp');
assert.doesNotMatch(body, /if\s*\(existingCode\)\s*\{\s*return existingCode;\s*\}/);
assert.match(body, /requestGoPayOtpInput\(\{ code: existingCode \}\)/);
assert.match(body, /检测到上次保存的 GoPay 验证码/);
});
test('GoPay approve handles final payment details iframe as an action frame', () => {
assert.match(source, /GOPAY_PAYMENT_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/details/);
assert.match(source, /app\\\/challenge/);
assert.match(source, /inspectGoPayFramesByDom/);
assert.match(source, /getGoPayDomFramePriority/);
assert.match(source, /paymentFrames/);
assert.match(source, /frameState\?\.hasPayNowButton/);
assert.match(source, /getGoPayDomFrameKind/);
assert.match(source, /return 'payment'/);
assert.match(source, /sendGoPayFrameCommand\(tabId, actionFrameId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /getGoPayDebuggerTargets/);
assert.match(source, /chrome\.debugger\.getTargets/);
assert.match(source, /targetId: picked\.targetId/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_SUBMIT_PIN'/);
assert.match(source, /Input\.insertText/);
assert.match(source, /最终 Bayar 确认/);
});
test('GoPay debugger click does not reuse iframe-relative rects as top-level coordinates', () => {
const body = extractFunction('clickGoPayTargetWithDebugger');
assert.match(body, /Number\.isInteger\(frameId\)/);
assert.match(body, /debugger_click_skipped_for_frame_target/);
assert.ok(
body.indexOf('debugger_click_skipped_for_frame_target') < body.indexOf('clickWithDebugger(tabId, rect)')
);
});
test('GoPay approve treats merchant validate-pin iframe as PIN entry frame', () => {
assert.match(source, /GOPAY_PIN_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/validate-pin/);
assert.match(source, /kind: 'pin'/);
assert.match(source, /GOPAY_SUBMIT_PIN/);
});
test('GoPay approve closes terminal checkout but does not restart on top-level Pay now alone', () => {
assert.match(source, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(source, /restartGoPayCheckoutFromStep6/);
assert.match(source, /chrome\?\.tabs\?\.remove/);
assert.match(source, /handleGoPayTerminalError\(pageState, tabId\)/);
assert.match(source, /nextState\.hasTerminalError/);
assert.doesNotMatch(source, /GoPay 顶层 Pay now 兜底点击后仍未进入下一步,当前支付会话需要重新创建/);
});
test('GoPay approve falls back to clicking Bayar inside any iframe before top-level Pay now retry', () => {
assert.match(source, /clickGoPayPayButtonInAnyFrame/);
assert.match(source, /data-testid/);
assert.match(source, /pay-button/);
assert.match(source, /已在 GoPay iframe 中点击 Bayar 按钮/);
assert.match(source, /不再自动回退步骤 6/);
});
test('GoPay approve does not treat phone linking page as debugger iframe action', () => {
assert.match(source, /type === 'tel'/);
assert.match(source, /const hasContinueButton = !hasPayNowButton && !hasPhoneInput/);
assert.match(source, /filter\(\(target\) => target\.type === 'iframe'\)/);
});
test('GoPay approve waits and retries slowly on Hubungkan linking page', () => {
assert.match(source, /GOPAY_LINKING_RETRY_WAIT_MS/);
assert.match(source, /GOPAY_LINKING_STABLE_WAIT_MS/);
assert.match(source, /createGoPayStableStateTracker/);
assert.match(source, /clickGoPayContinueBestEffort/);
assert.match(source, /hubungkan\|sambungkan\|tautkan/);
assert.match(source, /先等待 linking 页面加载\/跳转/);
assert.match(source, /改用兜底点击 Hubungkan\/确认按钮/);
assert.doesNotMatch(source, /GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。/);
});
test('background auto-run routes GoPay restart sentinel back to step 6', () => {
const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/);
});
test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => {
assert.match(source, /pageState\.hasPinInput && !pinSubmitted/);
assert.match(source, /pageState\.hasOtpInput && !pageState\.hasPinInput && !otpSubmitted/);
assert.ok(source.indexOf('pageState.hasPinInput && !pinSubmitted') < source.indexOf('pageState.hasOtpInput && !pageState.hasPinInput && !otpSubmitted'));
assert.doesNotMatch(source, /otp\|one\[-\\s\]\*time\|kode\|verification\|whatsapp\|code\|pin-input-field/);
});
+406
View File
@@ -0,0 +1,406 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/gopay-flow.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 index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for ${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);
}
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
const bundle = [
extractFunction('dispatchPointerMouseSequence'),
extractFunction('humanClickElement'),
].join('\n');
const events = [];
const button = {
tagName: 'BUTTON',
scrollIntoView() { events.push('scroll'); },
focus() { events.push('focus'); },
click() { events.push('native-click'); },
getBoundingClientRect() { return { left: 10, top: 20, width: 100, height: 40 }; },
dispatchEvent(event) {
events.push(event.type);
return true;
},
};
const api = new Function('button', 'events', `
const window = { screenX: 0, screenY: 0 };
class MouseEvent { constructor(type, init = {}) { this.type = type; this.init = init; } }
class PointerEvent extends MouseEvent {}
async function sleep() { events.push('sleep'); }
${bundle}
return { humanClickElement };
`)(button, events);
await api.humanClickElement(button, { beforeMs: 1, afterDispatchMs: 1, afterMs: 1 });
assert.deepEqual(events.slice(0, 3), ['scroll', 'sleep', 'focus']);
assert.ok(events.includes('pointerdown'));
assert.ok(events.includes('mousedown'));
assert.ok(events.includes('mouseup'));
assert.ok(events.includes('click'));
assert.equal(events.at(-2), 'native-click');
});
test('GoPay continue target exposes a debugger-clickable rect', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: 'link-and-pay',
className: 'btn primary',
textContent: 'Link and pay',
innerText: 'Link and pay',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 20, top: 30, width: 160, height: 44 }; },
};
const api = new Function('button', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [button] : [];
},
};
${bundle}
return { getGoPayContinueTarget };
`)(button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.equal(target.rect.centerX, 100);
assert.equal(target.rect.centerY, 52);
assert.match(target.target, /Link and pay/);
});
test('GoPay continue target recognizes Indonesian Hubungkan linking button', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: '',
className: 'btn primary',
textContent: 'Hubungkan',
innerText: 'Hubungkan',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 40, top: 720, width: 280, height: 48 }; },
};
const termsLink = {
...button,
tagName: 'A',
className: 'terms-link',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 120, top: 650, width: 120, height: 16 }; },
};
const api = new Function('button', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, button] : [];
},
};
${bundle}
return { findContinueButton, getGoPayContinueTarget };
`)(button, termsLink);
assert.equal(api.findContinueButton(), button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.match(target.target, /Hubungkan/);
assert.equal(target.rect.centerX, 180);
assert.equal(target.rect.centerY, 744);
});
test('GoPay PIN page detection wins over generic pin-input OTP attributes', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isGoPayPinInput'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('isCountrySearchInput'),
extractFunction('getCombinedElementText'),
extractFunction('findInputByPatterns'),
extractFunction('findOtpInput'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('normalizeOtp'),
extractFunction('fillVisibleOtpInputs'),
].join('\n');
const pinInputs = Array.from({ length: 6 }, (_, index) => ({
tagName: 'INPUT',
type: 'text',
id: '',
className: 'pin-input password',
textContent: '',
value: '',
placeholder: '○',
maxLength: 1,
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'maxlength') return '1';
if (name === 'data-testid') return `pin-input-${index}`;
if (name === 'class') return this.className;
if (name === 'placeholder') return this.placeholder;
return '';
},
getBoundingClientRect() { return { width: 40, height: 40 }; },
}));
const api = new Function('pinInputs', `
const location = { href: 'https://pin-web-client.gopayapi.com/auth/pin/verify' };
const window = { getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; } };
const document = {
body: { innerText: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN', textContent: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN' },
querySelectorAll(selector) { return selector.includes('input') ? pinInputs : []; },
};
${bundle}
return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs, fillVisibleOtpInputs };
`)(pinInputs);
assert.equal(api.isGoPayPinPageText(), true);
assert.equal(api.isGoPayOtpPageText(), false);
assert.equal(api.findOtpInput(), null);
assert.equal(api.fillVisibleOtpInputs('123456'), false);
assert.equal(api.findPinInput(), pinInputs[0]);
assert.equal(api.getGoPayPinInputs().length, 6);
});
test('GoPay Pay now button is detected separately from generic continue actions', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayPayNowTarget'),
].join('\n');
const payButton = {
tagName: 'BUTTON',
id: '',
className: 'btn full primary btn-theme',
textContent: 'Pay now',
innerText: 'Pay now',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 411, top: 689, width: 388, height: 38 }; },
};
const refreshButton = {
...payButton,
className: 'refresh-button',
textContent: 'Refresh',
innerText: 'Refresh',
getBoundingClientRect() { return { left: 705, top: 346, width: 90, height: 30 }; },
};
const api = new Function('payButton', 'refreshButton', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 1280,
innerHeight: 800,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [refreshButton, payButton] : [];
},
};
${bundle}
return { findPayNowButton, getGoPayPayNowTarget };
`)(payButton, refreshButton);
assert.equal(api.findPayNowButton(), payButton);
assert.equal(api.getGoPayPayNowTarget().found, true);
assert.match(api.getGoPayPayNowTarget().target, /Pay now/);
});
test('GoPay final Bayar amount button is detected without matching terms link', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
].join('\n');
const bayarButton = {
tagName: 'BUTTON',
className: 'bg-brand text-white',
textContent: 'Bayar\nRp 1',
innerText: 'Bayar\nRp 1',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 16, top: 556, width: 388, height: 44 }; },
};
const termsLink = {
...bayarButton,
tagName: 'A',
className: 'font-semibold text-brand cursor-pointer',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 224, top: 608, width: 104, height: 16 }; },
};
const api = new Function('bayarButton', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, bayarButton] : [];
},
};
${bundle}
return { findPayNowButton };
`)(bayarButton, termsLink);
assert.equal(api.findPayNowButton(), bayarButton);
});
test('GoPay terminal timeout page is reported as retry-required state', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isGoPayPinInput'),
extractFunction('detectGoPayTerminalError'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByPatterns'),
extractFunction('findPhoneInput'),
extractFunction('isCountrySearchInput'),
extractFunction('findOtpInput'),
extractFunction('getCombinedElementText'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('findContinueButton'),
extractFunction('readSelectedCountryCodeText'),
extractFunction('inspectGoPayState'),
].join('\n');
const api = new Function(`
const location = { href: 'https://merchants-gws-app.gopayapi.com/app/challenge?reference=test' };
const window = { getComputedStyle() { return { display: 'none', visibility: 'hidden', opacity: '0' }; } };
const document = {
body: { innerText: 'Yah, waktunya habis\\nKalau kamu mau coba lagi, tutup halaman ini dan ulangi prosesnya dari awal, ya.', textContent: '' },
readyState: 'complete',
querySelectorAll() { return []; },
};
${bundle}
return { inspectGoPayState, detectGoPayTerminalError };
`)();
const state = api.inspectGoPayState();
assert.equal(state.hasTerminalError, true);
assert.equal(state.terminalError.code, 'expired');
assert.match(state.terminalError.message, /重新创建 Plus Checkout|超时/);
});
+110
View File
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadGoPayUtils() {
const source = fs.readFileSync('gopay-utils.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.GoPayUtils;`)(globalScope);
}
test('GoPay utils normalize manual OTP input', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
assert.equal(api.normalizeGoPayOtp('abc'), '');
});
test('GoPay utils keeps GPC helper payment method distinct', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizePlusPaymentMethod('gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePlusPaymentMethod('gopay'), 'gopay');
assert.equal(api.normalizePlusPaymentMethod('unknown'), 'paypal');
});
test('GoPay utils builds GPC card balance URL from helper endpoints', () => {
const api = loadGoPayUtils();
assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gopay.hwork.pro');
assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gopay.hwork.pro');
assert.equal(
api.buildGpcHelperApiUrl('', '/api/checkout/start'),
'https://gopay.hwork.pro/api/checkout/start'
);
assert.equal(
api.buildGpcCardBalanceUrl('http://localhost:18473/', ' card key/1 '),
'http://localhost:18473/api/card/balance?card_key=card%20key%2F1'
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/checkout/start', 'GPC-1'),
'https://gopay.hwork.pro/api/card/balance?card_key=GPC-1'
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/card/balance?card_key=old', 'new'),
'https://gopay.hwork.pro/api/card/balance?card_key=new'
);
});
test('GoPay utils builds GPC OTP/PIN payloads with card_key and flow_id', () => {
const api = loadGoPayUtils();
assert.deepEqual(
api.buildGpcOtpPayload({
reference_id: ' ref_1 ',
otp: ' 12-34 56 ',
card_key: ' card_1 ',
gopay_guid: ' guid_1 ',
flow_id: ' flow_1 ',
redirect_url: 'https://pm-redirects.stripe.com/test',
}),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
gopay_guid: 'guid_1',
redirect_url: 'https://pm-redirects.stripe.com/test',
}
);
assert.deepEqual(
api.buildGpcOtpRetryPayload({ referenceId: 'ref_1', otp: '123456', cardKey: 'card_1', flowId: 'flow_1' }),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
code: '123456',
}
);
assert.deepEqual(
api.buildGpcPinPayload({
referenceId: 'ref_1',
challengeId: 'challenge_1',
gopayGuid: 'guid_1',
pin: '65-43-21',
cardKey: 'card_1',
flowId: 'flow_1',
}),
{
reference_id: 'ref_1',
challenge_id: 'challenge_1',
gopay_guid: 'guid_1',
pin: '654321',
card_key: 'card_1',
flow_id: 'flow_1',
}
);
});
test('GoPay utils formats balance and maps linked-account errors', () => {
const api = loadGoPayUtils();
assert.equal(
api.formatGpcBalancePayload({ remaining_uses: 12, card_status: 'active', flow_id: 'flow_1' }),
'余额 12,状态 activeflow_id flow_1'
);
assert.equal(
api.extractGpcResponseErrorDetail({ errors: [{ loc: ['body', 'otp'], msg: 'Field required' }] }, 422),
'body.otp: Field required'
);
assert.equal(
api.extractGpcResponseErrorDetail({ error_messages: ['account already linked'] }, 406),
'GOPAY已经绑了订阅,需要手动解绑'
);
});
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadPhoneCountryUtils() {
const source = fs.readFileSync('content/phone-country-utils.js', 'utf8');
const root = {};
return new Function('self', `${source}; return self.MultiPagePhoneCountryUtils;`)(root);
}
test('phone country utils extracts dial codes from current OpenAI country labels', () => {
const utils = loadPhoneCountryUtils();
assert.equal(utils.extractDialCodeFromText('United Kingdom (+44)'), '44');
assert.equal(utils.extractDialCodeFromText('United Kingdom +(44)'), '44');
assert.equal(utils.extractDialCodeFromText('United Kingdom +44'), '44');
});
test('phone country utils resolves country dial code from provider phone numbers', () => {
const utils = loadPhoneCountryUtils();
assert.equal(utils.resolveDialCodeFromPhoneNumber('447423278610'), '44');
assert.equal(utils.resolveDialCodeFromPhoneNumber('+8613800138000'), '86');
assert.equal(utils.resolveDialCodeFromPhoneNumber('12461234567'), '1246');
});
test('phone country utils finds country options by phone dial code and country aliases', () => {
const utils = loadPhoneCountryUtils();
const options = [
{ value: 'ID', textContent: 'Indonesia +(62)' },
{ value: 'GB', textContent: 'United Kingdom +(44)' },
];
assert.equal(utils.findOptionByPhoneNumber(options, '447423278610'), options[1]);
assert.equal(utils.findOptionByCountryLabel(options, 'England'), options[1]);
});
test('phone country utils is loaded before phone auth content scripts', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
const authScript = manifest.content_scripts.find((entry) => (
Array.isArray(entry.matches)
&& entry.matches.some((match) => match.includes('auth.openai.com'))
));
assert.ok(authScript, 'missing auth content script');
assert.ok(authScript.js.includes('content/phone-country-utils.js'));
assert.ok(
authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/phone-auth.js'),
'phone-country-utils.js must load before phone-auth.js'
);
assert.ok(
authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/signup-page.js'),
'phone-country-utils.js must load before signup-page.js'
);
const background = fs.readFileSync('background.js', 'utf8');
const injectLine = background.match(/const\s+SIGNUP_PAGE_INJECT_FILES\s*=\s*\[[^\n]+\]/)?.[0] || '';
assert.ok(injectLine.includes("'content/phone-country-utils.js'"));
assert.ok(
injectLine.indexOf("'content/phone-country-utils.js'") < injectLine.indexOf("'content/phone-auth.js'"),
'dynamic signup injection must load phone-country-utils.js before phone-auth.js'
);
});
+712 -13
View File
@@ -83,6 +83,464 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
assert.equal(requests[1].searchParams.get('api_key'), 'demo-key');
});
test('signup phone helper persists signup runtime state without touching add-phone activation', async () => {
const setStateCalls = [];
let currentState = {
heroSmsApiKey: 'demo-key',
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:signup-123:66959916439',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.prepareSignupPhoneActivation(currentState);
assert.equal(activation.activationId, 'signup-123');
assert.equal(activation.phoneNumber, '66959916439');
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
assert.deepStrictEqual(currentState.signupPhoneActivation, activation);
assert.equal(currentState.accountIdentifierType, 'phone');
assert.equal(currentState.accountIdentifier, '66959916439');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper polls signup SMS code and keeps activation purpose isolated', async () => {
const setStateCalls = [];
let currentState = {
heroSmsApiKey: 'demo-key',
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:123456',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const code = await helpers.waitForSignupPhoneCode(currentState, currentState.signupPhoneActivation);
assert.equal(code, '123456');
assert.equal(currentState.currentPhoneVerificationCode, '123456');
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationRequestedAt));
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper finalizes or cancels signup activation without clearing add-phone activation', async () => {
const setStateCalls = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsReuseEnabled: false,
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'setStatus') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await helpers.finalizeSignupPhoneActivationAfterSuccess(currentState, currentState.signupPhoneActivation);
assert.deepStrictEqual(statusActions, ['6']);
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneActivation, null);
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
});
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.accountIdentifierType, 'phone');
assert.equal(currentState.accountIdentifier, '66959916439');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper completes signup SMS verification without touching add-phone activation', async () => {
const setStateCalls = [];
const contentMessages = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsReuseEnabled: false,
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneNumber: '66959916439',
signupPhoneVerificationPurpose: 'signup',
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:123456',
};
}
if (action === 'setStatus') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async (_source, message) => {
contentMessages.push(message);
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completeSignupPhoneVerificationFlow(77, {
state: currentState,
signupProfile: {
firstName: 'Ada',
lastName: 'Lovelace',
year: 1995,
month: 1,
day: 2,
},
});
assert.deepStrictEqual(result, { success: true });
assert.deepStrictEqual(statusActions, ['6']);
assert.deepStrictEqual(contentMessages.map((message) => ({
type: message.type,
step: message.step,
code: message.payload?.code,
purpose: message.payload?.purpose,
})), [
{
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 4,
code: '123456',
purpose: 'signup',
},
]);
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneActivation, null);
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.currentPhoneVerificationCode, '');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper completes login SMS verification by reusing the completed signup activation', async () => {
const setStateCalls = [];
const contentMessages = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 1,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
const id = parsedUrl.searchParams.get('id');
if (action === 'reactivate' && id === 'signup-done') {
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
if (action === 'getStatus' && id === 'signup-done') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus' && id === 'signup-done') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}:${id}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async (_source, message) => {
contentMessages.push(message);
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completeLoginPhoneVerificationFlow(77, {
state: currentState,
visibleStep: 8,
});
assert.deepStrictEqual(result, { success: true });
assert.deepStrictEqual(statusActions, ['6']);
assert.deepStrictEqual(contentMessages.map((message) => ({
type: message.type,
step: message.step,
code: message.payload?.code,
purpose: message.payload?.purpose,
})), [
{
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 8,
code: '654321',
purpose: 'login',
},
]);
assert.equal(currentState.signupPhoneActivation, null);
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.currentPhoneVerificationCode, '');
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
activationId: 'signup-done',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 2,
maxUses: 3,
});
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationPurpose === 'login'));
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('phone verification helper ignores HeroSMS virtual-only stock when physicalCount is zero', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload({ count: 3, physicalCount: 0, cost: 0.05 }),
};
}
if (action === 'getNumber' || action === 'getNumberV2') {
return {
ok: true,
text: async () => 'NO_NUMBERS',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsActivationRetryRounds: 1 }),
/HeroSMS 已尝试 1 个候选国家,均无可用号码/
);
const actions = requests.map((requestUrl) => `${requestUrl.searchParams.get('action')}:${requestUrl.searchParams.get('maxPrice') || ''}`);
assert.deepStrictEqual(actions, [
'getPrices:',
'getPrices:',
'getPrices:',
'getNumber:',
'getNumberV2:',
'getPrices:',
'getPrices:',
'getPrices:',
'getNumber:',
'getNumberV2:',
]);
});
test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => {
const requests = [];
let getPricesAttempt = 0;
@@ -440,11 +898,11 @@ test('phone verification helper retries acquisition rounds when at least one cou
assert.equal(sleeps.length, 1);
assert.equal(sleeps[0], 2000);
assert.equal(
logs.filter((entry) => String(entry.message || '').includes('HeroSMS acquiring phone number')).length >= 2,
logs.filter((entry) => String(entry.message || '').includes('HeroSMS 正在获取手机号')).length >= 2,
true
);
assert.equal(
logs.some((entry) => String(entry.message || '').includes('HeroSMS has no available numbers (round 1/2); retrying')),
logs.some((entry) => String(entry.message || '').includes('HeroSMS 暂无可用号码(第 1/2 轮)')),
true
);
});
@@ -589,6 +1047,7 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
stateUpdates.some((entry) => entry?.currentPhoneActivation === null && entry?.currentPhoneVerificationCode === ''),
true
);
assert.equal(currentState.phoneNumber, '447911123456');
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, [
'getPrices',
@@ -919,6 +1378,43 @@ test('phone verification helper acquires a number from 5sim with fallback countr
assert.equal(requests[3].pathname, '/v1/user/buy/activation/england/any/openai');
});
test('phone verification helper rejects 5sim maxPrice with custom operator before buying', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
requests.push(url);
throw new Error(`Unexpected 5sim request: ${url}`);
},
getState: async () => ({
phoneSmsProvider: '5sim',
fiveSimApiKey: 'five-token',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'virtual21',
fiveSimMaxPrice: '0.1',
heroSmsActivationRetryRounds: 1,
}),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => helpers.requestPhoneActivation({
phoneSmsProvider: '5sim',
fiveSimApiKey: 'five-token',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'virtual21',
fiveSimMaxPrice: '0.1',
heroSmsActivationRetryRounds: 1,
}),
/maxPrice only works when operator is "any"/
);
assert.deepStrictEqual(requests, []);
});
test('phone verification helper honors price-priority ordering for 5sim countries', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
@@ -1956,7 +2452,7 @@ test('phone verification helper replaces numbers in step 9 and stops after repla
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 3 number replacements/i
/更换 3 次号码后手机号验证仍未成功/
);
assert.ok(statusCallsById['123456'] >= 2, 'first number should be polled twice before being replaced');
assert.ok(messages.includes('SUBMIT_PHONE_NUMBER'));
@@ -2043,7 +2539,7 @@ test('phone verification helper honors timeout-window and poll-round settings be
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 1 number replacements/i
/更换 1 次号码后手机号验证仍未成功/
);
assert.equal(messages.includes('RESEND_PHONE_VERIFICATION_CODE'), false);
@@ -2143,7 +2639,7 @@ test('phone verification helper respects configured number replacement limit', a
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 1 number replacements/i
/更换 1 次号码后手机号验证仍未成功/
);
const actions = requests.map((requestUrl) => requestUrl.searchParams.get('action'));
@@ -4138,8 +4634,8 @@ test('phone verification helper logs no-supply diagnostics with consecutive stre
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, options });
},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
@@ -4185,20 +4681,223 @@ test('phone verification helper logs no-supply diagnostics with consecutive stre
await runOnce();
const diagnosticsLogs = logs
.filter((entry) => String(entry.message || '').includes('Step 9 diagnostics: 无号连续失败'))
.map((entry) => String(entry.message || ''));
.filter((entry) => String(entry.message || '').includes('diagnostics: 无号连续失败'));
assert.equal(diagnosticsLogs.length >= 2, true);
assert.equal(diagnosticsLogs.some((message) => message.includes('无号连续失败 1 次')), true);
assert.equal(diagnosticsLogs.some((message) => message.includes('无号连续失败 2 次')), true);
assert.equal(diagnosticsLogs.every((entry) => entry.options?.step === 9), true);
assert.equal(diagnosticsLogs.every((entry) => entry.options?.stepKey === 'phone-verification'), true);
assert.equal(diagnosticsLogs.some((entry) => entry.message.includes('无号连续失败 1 次')), true);
assert.equal(diagnosticsLogs.some((entry) => entry.message.includes('无号连续失败 2 次')), true);
assert.equal(
diagnosticsLogs.some((message) => message.includes('maxPrice=0.06')),
diagnosticsLogs.some((entry) => entry.message.includes('maxPrice=0.06')),
true
);
assert.equal(
diagnosticsLogs.some((message) => message.includes('国家数 HeroSMS=1, 5sim=0, NexSMS=0')),
diagnosticsLogs.some((entry) => entry.message.includes('国家数 HeroSMS=1, 5sim=0, NexSMS=0')),
true
);
assert.equal(currentState.phoneNoSupplyFailureStreak, 2);
assert.equal(requests.some((entry) => entry.searchParams.get('action') === 'getNumber'), true);
});
test('phone verification helper routes 5sim buy, check, and finish by current activation provider', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimMaxPrice: '12',
fiveSimOperator: 'any',
verificationResendCount: 0,
phoneVerificationReplacementLimit: 2,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url, options = {}) => {
const parsedUrl = new URL(url);
requests.push({ url: parsedUrl, options });
if (parsedUrl.pathname === '/v1/guest/products/vietnam/any') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ openai: { Category: 'activation', Qty: 3, Price: 9.5 } }),
};
}
if (parsedUrl.pathname === '/v1/guest/prices') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ vietnam: { any: { openai: { cost: 9.5, count: 3 } } } }),
};
}
if (parsedUrl.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 5001, phone: '+84901122334', country: 'vietnam', operator: 'any', status: 'PENDING' }),
};
}
if (parsedUrl.pathname === '/v1/user/check/5001') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 5001, phone: '+447911223344', status: 'RECEIVED', sms: [{ text: 'OpenAI code 123456' }] }),
};
}
if (parsedUrl.pathname === '/v1/user/finish/5001') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'FINISHED' }),
};
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.currentPhoneActivation, null);
assert.equal(currentState.reusablePhoneActivation.provider, '5sim');
assert.equal(currentState.reusablePhoneActivation.activationId, '5001');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 1);
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '12');
assert.equal(buy.url.searchParams.get('reuse'), '1');
assert.equal(buy.options.headers.Authorization, 'Bearer demo-key');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
'/v1/user/check/5001',
'/v1/user/finish/5001',
]
);
});
test('phone verification helper routes 5sim reusable activation through reuse endpoint', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
verificationResendCount: 0,
phoneVerificationReplacementLimit: 2,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: {
activationId: '4001',
phoneNumber: '+84901122334',
provider: '5sim',
serviceCode: 'openai',
countryId: 'vietnam',
countryLabel: '越南 (Vietnam)',
successfulUses: 1,
maxUses: 3,
},
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
if (parsedUrl.pathname === '/v1/user/reuse/openai/84901122334') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+84901122334', country: 'vietnam', status: 'PENDING' }) };
}
if (parsedUrl.pathname === '/v1/user/check/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', status: 'RECEIVED', sms: [{ code: '654321' }] }) };
}
if (parsedUrl.pathname === '/v1/user/finish/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ status: 'FINISHED' }) };
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.reusablePhoneActivation.activationId, '4002');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 1);
assert.deepStrictEqual(
requests.map((url) => url.pathname),
[
'/v1/user/reuse/openai/84901122334',
'/v1/user/check/4002',
'/v1/user/finish/4002',
]
);
});
+492
View File
@@ -38,6 +38,121 @@ test('plus checkout content script can be injected repeatedly on the same page',
assert.equal(context.__MULTIPAGE_PLUS_CHECKOUT_READY__, true);
});
function createPlusCheckoutMessageHarness({ checkoutSessionId = 'cs_test_123' } = {}) {
const attrs = new Map();
let listener = null;
const fetchCalls = [];
const context = {
console: { log() {}, warn() {}, error() {}, info() {} },
location: { href: 'https://chatgpt.com/' },
window: {},
document: {
readyState: 'complete',
documentElement: {
getAttribute(name) {
return attrs.get(name) || null;
},
setAttribute(name, value) {
attrs.set(name, String(value));
},
},
},
chrome: {
runtime: {
onMessage: {
addListener(fn) {
listener = fn;
},
},
},
},
resetStopState() {},
isStopError() { return false; },
throwIfStopped() {},
sleep() { return Promise.resolve(); },
log() {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url === '/api/auth/session') {
return {
ok: true,
status: 200,
json: async () => ({ accessToken: 'test-access-token' }),
};
}
if (url === 'https://chatgpt.com/backend-api/payments/checkout') {
return {
ok: true,
status: 200,
json: async () => ({ checkout_session_id: checkoutSessionId }),
};
}
throw new Error(`unexpected fetch url: ${url}`);
},
};
context.window = context;
vm.createContext(context);
vm.runInContext(source, context);
assert.equal(typeof listener, 'function');
async function send(message) {
return await new Promise((resolve) => {
listener(message, {}, resolve);
});
}
return { send, fetchCalls };
}
test('CREATE_PLUS_CHECKOUT keeps PayPal on DE/EUR and openai_ie merchant path by default', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_paypal' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: {},
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_ie/cs_paypal');
assert.equal(result.country, 'DE');
assert.equal(result.currency, 'EUR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
assert.equal(checkoutCall.options.method, 'POST');
assert.equal(checkoutCall.options.headers.Authorization, 'Bearer test-access-token');
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.plan_name, 'chatgptplusplan');
assert.deepEqual(payload.billing_details, { country: 'DE', currency: 'EUR' });
});
test('CREATE_PLUS_CHECKOUT uses ID/IDR and openai_llc merchant path for GoPay', async () => {
const harness = createPlusCheckoutMessageHarness({ checkoutSessionId: 'cs_gopay' });
const result = await harness.send({
type: 'CREATE_PLUS_CHECKOUT',
source: 'test',
payload: { paymentMethod: 'gopay' },
});
assert.equal(result.ok, true);
assert.equal(result.checkoutUrl, 'https://chatgpt.com/checkout/openai_llc/cs_gopay');
assert.equal(result.country, 'ID');
assert.equal(result.currency, 'IDR');
const checkoutCall = harness.fetchCalls.find((call) => call.url === 'https://chatgpt.com/backend-api/payments/checkout');
assert.ok(checkoutCall);
const payload = JSON.parse(checkoutCall.options.body);
assert.equal(payload.entry_point, 'all_plans_pricing_modal');
assert.equal(payload.checkout_ui_mode, 'custom');
assert.deepEqual(payload.billing_details, { country: 'ID', currency: 'IDR' });
assert.deepEqual(payload.promo_campaign, {
promo_campaign_id: 'plus-1-month-free',
is_coupon_from_query_param: false,
});
});
function extractFunction(name) {
const plainStart = source.indexOf(`function ${name}(`);
const asyncStart = source.indexOf(`async function ${name}(`);
@@ -180,8 +295,80 @@ return { findAddressSearchInput, isNonAddressSearchInput };
assert.equal(await api.findAddressSearchInput(), addressInput);
});
test('getCheckoutAmountSummary reads non-zero today due amount', () => {
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
const amount = createElement({ tagName: 'DIV', text: '€19.33' });
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €19.33' });
label.parentElement = row;
amount.parentElement = row;
row.children = [label, amount];
row.parentElement = null;
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('parseLocalizedAmount'),
extractFunction('getTextAfterTodayDueLabel'),
extractFunction('getVisibleControls'),
extractFunction('getCheckoutAmountSummary'),
'return { getCheckoutAmountSummary };',
].join('\n');
const api = new Function('window', 'document', bundle)(
{
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
},
{
querySelectorAll: () => [label, amount, row],
}
);
const summary = api.getCheckoutAmountSummary();
assert.equal(summary.hasTodayDue, true);
assert.equal(summary.isZero, false);
assert.equal(summary.amount, 19.33);
assert.equal(summary.rawAmount, '€19.33');
});
test('getCheckoutAmountSummary accepts zero today due amount', () => {
const label = createElement({ tagName: 'DIV', text: '今日应付金额' });
const amount = createElement({ tagName: 'DIV', text: '€0.00' });
const row = createElement({ tagName: 'DIV', text: '今日应付金额 €0.00' });
label.parentElement = row;
amount.parentElement = row;
row.children = [label, amount];
row.parentElement = null;
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('parseLocalizedAmount'),
extractFunction('getTextAfterTodayDueLabel'),
extractFunction('getVisibleControls'),
extractFunction('getCheckoutAmountSummary'),
'return { getCheckoutAmountSummary };',
].join('\n');
const api = new Function('window', 'document', bundle)(
{
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
},
{
querySelectorAll: () => [label, amount, row],
}
);
const summary = api.getCheckoutAmountSummary();
assert.equal(summary.hasTodayDue, true);
assert.equal(summary.isZero, true);
assert.equal(summary.amount, 0);
});
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
@@ -191,9 +378,15 @@ test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
extractFunction('getVisibleControls'),
extractFunction('getVisibleTextInputs'),
extractFunction('isDocumentLevelContainer'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getPayPalSearchCandidates'),
extractFunction('hasCreditCardFields'),
extractFunction('hasPaymentMethodSelectionMarker'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedPayPalControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isPayPalPaymentMethodActive'),
].join('\n');
@@ -234,6 +427,164 @@ return { isPayPalPaymentMethodActive };
assert.equal(api.isPayPalPaymentMethodActive(), true);
});
test('getStructuredAddressFields recognizes Stripe localized address field names', () => {
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getFieldText'),
extractFunction('getVisibleControls'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByFieldText'),
extractFunction('getStructuredAddressFields'),
].join('\n');
const address1 = createInput({ name: 'addressLine1', placeholder: '住所' });
const city = createInput({ name: 'locality', placeholder: '市区町村' });
const region = createInput({ name: 'administrativeArea', placeholder: '辖区' });
const postalCode = createInput({ name: 'postalCode', placeholder: '郵便番号' });
const inputs = [address1, city, region, postalCode];
const documentMock = {
querySelectorAll: (selector) => {
if (selector === 'input, textarea') return inputs;
if (String(selector || '').includes('label[for=')) return [];
return [];
},
};
const windowMock = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
${bundle}
return { getStructuredAddressFields };
`)(windowMock, documentMock, cssMock);
assert.deepEqual(api.getStructuredAddressFields(), {
address1,
address2: null,
city,
region,
postalCode,
});
});
test('findSubscribeButton prefers the submit subscription button', () => {
const bundle = [
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('findClickableByText'),
extractFunction('isEnabledControl'),
extractFunction('findSubscribeButton'),
].join('\n');
const submitButton = createElement({
tagName: 'BUTTON',
text: '订阅',
attrs: {
'aria-label': '订阅',
type: 'submit',
},
});
submitButton.type = 'submit';
const genericButton = createElement({
tagName: 'BUTTON',
text: '继续',
attrs: {
type: 'button',
},
});
genericButton.type = 'button';
const documentMock = {
body: {},
documentElement: {},
querySelectorAll: (selector) => {
if (selector === 'button[type="submit"], input[type="submit"]') return [submitButton];
if (selector === 'button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]') {
return [genericButton, submitButton];
}
if (String(selector || '').includes('label[for=')) return [];
return [];
},
};
const windowMock = {
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
${bundle}
return { findSubscribeButton };
`)(windowMock, documentMock, cssMock);
assert.equal(api.findSubscribeButton(), submitButton);
});
test('humanLikeClick submits a detached submit button through its form attribute', async () => {
const bundle = [
'function throwIfStopped() {}',
'function sleep() { return Promise.resolve(); }',
'function summarizeElementForDebug() { return {}; }',
'function log() {}',
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getAssociatedForm'),
extractFunction('humanLikeClick'),
].join('\n');
let submittedWith = null;
const form = {
requestSubmit(button) {
submittedWith = button;
},
};
const button = createElement({
tagName: 'BUTTON',
text: '订阅',
attrs: {
form: '_r_l_',
type: 'submit',
'aria-label': '订阅',
},
});
button.type = 'submit';
button.scrollIntoView = () => {};
button.focus = () => {};
button.dispatchEvent = () => true;
button.click = () => {};
const documentMock = {
getElementById: (id) => (id === '_r_l_' ? form : null),
};
const windowMock = {};
class FakeMouseEvent {
constructor(type, init = {}) {
this.type = type;
Object.assign(this, init);
}
}
const api = new Function('window', 'document', 'MouseEvent', 'PointerEvent', 'console', `
${bundle}
return { humanLikeClick };
`)(windowMock, documentMock, FakeMouseEvent, undefined, { log() {} });
await api.humanLikeClick(button);
assert.equal(submittedWith, button);
});
test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => {
const bundle = [
'function throwIfStopped() {}',
@@ -352,6 +703,147 @@ return { findCountryDropdown, findRegionDropdown, matchesCountryOption, matchesR
assert.equal(api.matchesRegionOption('東京都', 'Tokyo'), true);
});
test('payment method helpers can find and confirm selected GoPay controls', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('isDocumentLevelContainer'),
extractFunction('isPaymentCardSized'),
extractFunction('findInteractiveAncestor'),
extractFunction('findPaymentCardAncestor'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('getGoPaySearchCandidates'),
extractFunction('findPaymentMethodTarget'),
extractFunction('findGoPayPaymentMethodTarget'),
extractFunction('hasPaymentMethodSelectionMarker'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedGoPayControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isGoPayPaymentMethodActive'),
].join('\n');
const gopayButton = createElement({
text: 'GoPay',
attrs: {
id: 'gopay-tab',
role: 'tab',
'data-testid': 'gopay',
'aria-selected': 'true',
value: 'gopay',
},
});
const elements = [gopayButton];
const documentMock = {
documentElement: {},
body: {},
querySelectorAll: (selector) => {
if (String(selector || '').includes('label[for=')) return [];
return elements;
},
};
const windowMock = {
innerWidth: 1200,
innerHeight: 900,
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
function findClickableByText(patterns) {
return elements.find((el) => patterns.some((pattern) => pattern.test(getCombinedSearchText(el)))) || null;
}
const elements = document.querySelectorAll('*');
${bundle}
return { findGoPayPaymentMethodTarget, getGoPaySearchCandidates, hasSelectedGoPayControl, isGoPayPaymentMethodActive };
`)(windowMock, documentMock, cssMock);
assert.equal(api.findGoPayPaymentMethodTarget(), gopayButton);
assert.equal(api.getGoPaySearchCandidates()[0], gopayButton);
assert.equal(api.hasSelectedGoPayControl(), true);
assert.equal(api.isGoPayPaymentMethodActive(), true);
});
test('GoPay active detection accepts nested selected radio inside payment card', () => {
const bundle = [
"const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';",
"const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';",
"const PAYMENT_METHOD_CONFIGS = { paypal: { id: 'paypal', label: 'PayPal', patterns: [/paypal/i] }, gopay: { id: 'gopay', label: 'GoPay', patterns: [/gopay|go\\s*pay/i] } };",
extractFunction('isVisibleElement'),
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getSearchText'),
extractFunction('getFieldText'),
extractFunction('getCombinedSearchText'),
extractFunction('getVisibleControls'),
extractFunction('isDocumentLevelContainer'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getPaymentMethodConfig'),
extractFunction('getPaymentMethodSearchCandidates'),
extractFunction('hasPaymentMethodSelectionMarker'),
extractFunction('hasSelectedPaymentMethodControl'),
extractFunction('hasSelectedGoPayControl'),
extractFunction('isPaymentMethodActive'),
extractFunction('isGoPayPaymentMethodActive'),
].join('\n');
const radio = createElement({
tagName: 'INPUT',
attrs: {
type: 'radio',
role: 'radio',
'aria-checked': 'true',
value: 'gopay',
},
});
radio.checked = true;
const textNode = createElement({ tagName: 'SPAN', text: 'GoPay' });
const card = createElement({ tagName: 'DIV', text: 'GoPay' });
radio.parentElement = card;
textNode.parentElement = card;
card.children = [radio, textNode];
card.querySelector = (selector) => String(selector || '').includes('radio') ? radio : null;
const elements = [card, radio, textNode];
const documentMock = {
documentElement: {},
body: {},
querySelectorAll: (selector) => {
if (String(selector || '').includes('label[for=')) return [];
return elements.filter((element) => {
if (String(selector || '').includes('input[type="radio"]')) return element === radio || element === card || element === textNode;
return true;
});
},
};
const windowMock = {
innerWidth: 1200,
innerHeight: 900,
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
};
const cssMock = {
escape: (value) => String(value),
};
const api = new Function('window', 'document', 'CSS', `
${bundle}
return { isGoPayPaymentMethodActive };
`)(windowMock, documentMock, cssMock);
assert.equal(api.isGoPayPaymentMethodActive(), true);
});
test('fillIfEmpty can overwrite invalid structured address values in the dropdown branch', () => {
const bundle = [
extractFunction('fillIfEmpty'),
@@ -36,6 +36,34 @@ function createAuAddressSeed() {
};
}
function createIdAddressSeed() {
return {
countryCode: 'ID',
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
};
}
function createKrAddressSeed() {
return {
countryCode: 'KR',
query: 'Seoul Jung-gu',
suggestionIndex: 1,
fallback: {
address1: 'Sejong-daero 110',
city: 'Jung-gu',
region: 'Seoul',
postalCode: '04524',
},
};
}
function createSuccessfulBillingResult() {
return {
countryText: 'Germany',
@@ -53,6 +81,10 @@ function createExecutorHarness({
readyByFrame = {},
fetchImpl = null,
getAddressSeedForCountry = () => createAddressSeed(),
getState = null,
markCurrentRegistrationAccountUsed = async () => {},
probeIpProxyExit = null,
submitRedirectUrl = 'https://www.paypal.com/checkoutnow',
}) {
const api = loadPlusCheckoutBillingModule();
const events = {
@@ -100,6 +132,9 @@ function createExecutorHarness({
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
}
if (message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE') {
checkoutTab.url = submitRedirectUrl;
}
return createSuccessfulBillingResult();
},
},
@@ -119,21 +154,60 @@ function createExecutorHarness({
fetch: fetchImpl,
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }),
getAddressSeedForCountry,
getState: typeof getState === 'function' ? getState : async () => ({}),
getTabId: async () => null,
isTabAlive: async () => false,
markCurrentRegistrationAccountUsed,
setState: async (updates) => events.states.push(updates),
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => checkoutTab,
waitForTabUrlMatchUntilStopped: async (tabId, matcher) => {
events.waitedUrls.push({ tabId });
assert.equal(matcher('https://www.paypal.com/checkoutnow'), true);
return { id: tabId, url: 'https://www.paypal.com/checkoutnow' };
assert.equal(matcher(submitRedirectUrl), true);
return { id: tabId, url: submitRedirectUrl };
},
...(typeof probeIpProxyExit === 'function' ? { probeIpProxyExit } : {}),
});
return { checkoutTab, events, executor };
}
test('Plus checkout billing stops before PayPal when today due amount is non-zero', async () => {
const markCalls = [];
const { events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
stateByFrame: {
0: {
hasPayPal: true,
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
billingFieldsVisible: true,
hasSubscribeButton: true,
checkoutAmountSummary: {
hasTodayDue: true,
amount: 19.33,
isZero: false,
rawAmount: '€19.33',
},
},
},
markCurrentRegistrationAccountUsed: async (state, options) => {
markCalls.push({ state, options });
return { updated: true };
},
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({ email: 'paid@example.com' }),
/PLUS_CHECKOUT_NON_FREE_TRIAL::/
);
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE'), false);
assert.equal(events.completed.length, 0);
assert.equal(markCalls.length, 1);
assert.equal(markCalls[0].state.email, 'paid@example.com');
assert.equal(events.logs.some((entry) => /今日应付金额不是 0/.test(entry.message)), true);
});
test('Plus checkout billing uses the current checkout tab when step 6 did not register one', async () => {
const { checkoutTab, events, executor } = createExecutorHarness({
frames: [{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' }],
@@ -184,6 +258,306 @@ test('Plus checkout billing sends the billing command to the iframe that contain
assert.equal(events.completed[0].step, 7);
});
test('Plus checkout billing uses proxy exit country for GoPay address when available', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/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, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'JP' ? {
countryCode: 'JP',
query: 'Tokyo Marunouchi',
suggestionIndex: 1,
fallback: {
address1: 'Marunouchi 1-1',
city: 'Chiyoda-ku',
region: 'Tokyo',
postalCode: '100-0005',
},
} : createIdAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: 'トウキョウト, チヨダク, マルノウチ, 1-1',
Trans_Address: 'Marunouchi 1-1, Chiyoda-ku, Tokyo',
City: 'Tokyo',
State: 'Tokyo',
Zip_Code: '100-0005',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'JP',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'JP');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Chiyoda-ku',
path: '/jp-address',
method: 'refresh',
});
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true);
});
test('Plus checkout billing refreshes stale GoPay proxy country before filling address', async () => {
const requestedCountries = [];
const probeCalls = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/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, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'JP' ? {
countryCode: 'JP',
query: 'Tokyo Chiyoda-ku',
suggestionIndex: 1,
fallback: {
address1: 'Marunouchi 1-1',
city: 'Chiyoda-ku',
region: 'Tokyo',
postalCode: '100-0005',
},
} : createKrAddressSeed();
},
fetchImpl: async () => ({
ok: false,
status: 503,
json: async () => ({ status: 'error' }),
}),
probeIpProxyExit: async (options) => {
probeCalls.push(options);
return {
proxyRouting: {
exitRegion: 'JP',
exitIp: '203.0.113.8',
exitSource: 'page_context',
exitEndpoint: 'https://ipinfo.io/json',
},
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(probeCalls.length, 1);
assert.equal(probeCalls[0].detectWhenDisabled, true);
assert.equal(requestedCountries[0], 'JP');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'JP');
assert.equal(events.logs.some((entry) => entry.message.includes('当前代理出口复测结果:JP / 203.0.113.8')), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 JP/.test(entry.message)), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false);
});
test('Plus checkout billing refuses to reuse stale GoPay proxy country when refresh has no region', async () => {
const requestedCountries = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/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, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return createKrAddressSeed();
},
probeIpProxyExit: async () => ({
proxyRouting: {
reason: 'disabled_probe_only',
exitIp: '203.0.113.9',
exitRegion: '',
exitError: 'missing_region',
},
}),
});
await assert.rejects(
() => executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
}),
/本次复测没有拿到国家码/
);
assert.equal(requestedCountries.length, 0);
assert.equal(events.logs.some((entry) => /已清空旧出口地区 KR/.test(entry.message)), true);
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), false);
});
test('Plus checkout billing normalizes legacy Korean postal code for GoPay address', async () => {
const requestedCountries = [];
const fetchRequests = [];
const { events, executor } = createExecutorHarness({
frames: [
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_llc/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, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'United States',
},
},
getAddressSeedForCountry: (countryValue) => {
requestedCountries.push(countryValue);
return countryValue === 'KR' ? createKrAddressSeed() : createIdAddressSeed();
},
fetchImpl: async (url, init) => {
fetchRequests.push({ url, init });
return {
ok: true,
status: 200,
json: async () => ({
status: 'ok',
address: {
Address: '서울특별시 중구 세종대로 110',
Trans_Address: 'Sejong-daero 110, Jung-gu, Seoul',
City: 'Jung-gu',
State: 'Seoul',
Zip_Code: '150-300',
},
}),
};
},
submitRedirectUrl: 'https://app.midtrans.com/snap/v4/redirection/session#/gopay-tokenization/linking',
});
await executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gopay',
plusCheckoutCountry: 'ID',
ipProxyAppliedExitRegion: 'KR',
});
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
assert.equal(requestedCountries[0], 'KR');
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'KR');
assert.equal(fillMessage.message.payload.addressSeed.source, 'meiguodizhi');
assert.equal(fillMessage.message.payload.addressSeed.fallback.address1, 'Sejong-daero 110, Jung-gu, Seoul');
assert.equal(fillMessage.message.payload.addressSeed.fallback.postalCode, '04524');
assert.match(fillMessage.message.payload.addressSeed.fallback.postalCode, /^\d{5}$/);
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
city: 'Jung-gu',
path: '/kr-address',
method: 'refresh',
});
assert.equal(events.logs.some((entry) => /GoPay 账单地址将按当前代理出口地区 KR/.test(entry.message)), true);
});
test('Plus checkout billing selects GoPay and waits for a GoPay redirect', async () => {
const { checkoutTab, 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, hasGoPay: false, paypalCandidates: [], gopayCandidates: [], hasSubscribeButton: true },
7: { hasPayPal: false, hasGoPay: true, gopayCandidates: [{ tag: 'button', text: 'GoPay' }] },
8: {
hasPayPal: false,
hasGoPay: false,
paypalCandidates: [],
gopayCandidates: [],
billingFieldsVisible: true,
countryText: 'Indonesia',
},
},
getAddressSeedForCountry: () => createIdAddressSeed(),
fetchImpl: async () => ({
ok: false,
status: 404,
json: async () => ({ status: 'error' }),
}),
submitRedirectUrl: 'https://gopay.co.id/payment/session',
});
await executor.executePlusCheckoutBilling({ plusPaymentMethod: 'gopay' });
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_GOPAY');
const paypalSelectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
const fillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
const subscribeMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE');
assert.equal(selectMessage.frameId, 7);
assert.equal(selectMessage.message.payload.paymentMethod, 'gopay');
assert.equal(paypalSelectMessage, undefined);
assert.equal(fillMessage.message.payload.addressSeed.countryCode, 'ID');
assert.equal(subscribeMessage.message.payload.paymentMethod, 'gopay');
assert.equal(checkoutTab.url, 'https://gopay.co.id/payment/session');
assert.equal(events.completed[0].step, 7);
});
test('Plus checkout billing still inspects a frame when ping readiness is stale', async () => {
const { events, executor } = createExecutorHarness({
frames: [
@@ -423,3 +797,149 @@ test('Plus checkout billing reports when the payment iframe exists but cannot re
/已定位到 PayPal 所在 iframeframeId=7),但账单脚本无法注入该 iframe/
);
});
test('GPC billing normalizes API URL and submits OTP then PIN with card_key and flow_id', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: '',
};
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.endsWith('/api/gopay/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ reference_id: 'ref_123', challenge_id: 'challenge_456' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_123',
gopayHelperGoPayGuid: 'guid_789',
gopayHelperApiUrl: 'https://gopay.hwork.pro/api/checkout/start',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_billing_123',
gopayHelperFlowId: 'flow_billing_123',
});
await new Promise((resolve) => setTimeout(resolve, 20));
const pending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp');
assert.ok(pending);
currentState = {
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: pending.plusManualConfirmationRequestId,
gopayHelperResolvedOtp: '123456',
};
await run;
assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/gopay/otp');
assert.deepEqual(JSON.parse(fetchCalls[0].options.body), {
reference_id: 'ref_123',
otp: '123456',
card_key: 'card_billing_123',
flow_id: 'flow_billing_123',
gopay_guid: 'guid_789',
});
assert.equal(fetchCalls[1].url, 'https://gopay.hwork.pro/api/gopay/pin');
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_123',
challenge_id: 'challenge_456',
gopay_guid: 'guid_789',
pin: '654321',
card_key: 'card_billing_123',
flow_id: 'flow_billing_123',
});
assert.equal(events.completed[0].step, 7);
assert.equal(events.completed[0].payload.plusCheckoutSource, 'gpc-helper');
});
test('GPC billing retries OTP with compatibility field after HTTP 400', async () => {
const fetchCalls = [];
let currentState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: '',
};
const { events, executor } = createExecutorHarness({
frames: [],
stateByFrame: {},
getState: async () => currentState,
fetchImpl: async (url, options = {}) => {
fetchCalls.push({ url, options });
if (url.endsWith('/api/gopay/otp') && fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length === 1) {
return {
ok: false,
status: 400,
json: async () => ({ error: 'otp field invalid' }),
};
}
if (url.endsWith('/api/gopay/otp')) {
return {
ok: true,
status: 200,
json: async () => ({ challenge_id: 'challenge_retry' }),
};
}
if (url.endsWith('/api/gopay/pin')) {
return {
ok: true,
status: 200,
json: async () => ({ stage: 'gopay_complete' }),
};
}
throw new Error(`unexpected url: ${url}`);
},
});
const run = executor.executePlusCheckoutBilling({
plusPaymentMethod: 'gpc-helper',
plusCheckoutSource: 'gpc-helper',
gopayHelperReferenceId: 'ref_retry',
gopayHelperGoPayGuid: 'guid_retry',
gopayHelperRedirectUrl: 'https://pm-redirects.stripe.com/retry',
gopayHelperApiUrl: 'http://localhost:18473/',
gopayHelperPin: '654321',
gopayHelperCardKey: 'card_retry',
gopayHelperFlowId: 'flow_retry',
});
await new Promise((resolve) => setTimeout(resolve, 20));
const pending = events.states.find((state) => state.plusManualConfirmationMethod === 'gopay-otp');
currentState = {
plusManualConfirmationPending: false,
plusManualConfirmationRequestId: pending.plusManualConfirmationRequestId,
gopayHelperResolvedOtp: '123456',
};
await run;
assert.equal(fetchCalls.filter((call) => call.url.endsWith('/api/gopay/otp')).length, 2);
assert.deepEqual(JSON.parse(fetchCalls[1].options.body), {
reference_id: 'ref_retry',
otp: '123456',
card_key: 'card_retry',
flow_id: 'flow_retry',
gopay_guid: 'guid_retry',
redirect_url: 'https://pm-redirects.stripe.com/retry',
code: '123456',
});
assert.equal(events.logs.some((entry) => /兼容字段重试/.test(entry.message)), true);
assert.equal(events.completed[0].step, 7);
});
+218
View File
@@ -106,3 +106,221 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
});
test('GPC checkout injects Plus script before reading ChatGPT session token and sends card_key', async () => {
const events = [];
const fetchCalls = [];
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info') => events.push({ type: 'log', message, level }),
chrome: {
tabs: {
create: async (payload) => {
events.push({ type: 'tab-create', payload });
return { id: 77 };
},
remove: async (tabId) => events.push({ type: 'tab-remove', tabId }),
},
},
completeStepFromBackground: async (step, payload) => events.push({ type: 'complete', step, payload }),
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId, options) => events.push({ type: 'ready', source, tabId, options }),
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({
reference_id: 'ref_123',
gopay_guid: 'guid_456',
next_action: 'enter_otp',
flow_id: 'flow_789',
}),
};
},
registerTab: async (source, tabId) => events.push({ type: 'register', source, tabId }),
sendTabMessageUntilStopped: async (tabId, source, message) => {
events.push({ type: 'tab-message', tabId, source, message });
return { accessToken: 'session-access-token' };
},
setState: async (payload) => events.push({ type: 'set-state', payload }),
sleepWithStop: async (ms) => events.push({ type: 'sleep', ms }),
waitForTabCompleteUntilStopped: async () => events.push({ type: 'tab-complete' }),
});
await executor.executePlusCheckoutCreate({
email: 'Current.Round+GPC@Example.COM',
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
gopayHelperPhoneNumber: '+8613800138000',
gopayPhone: '',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_test_123',
});
const readyIndex = events.findIndex((event) => event.type === 'ready');
const messageIndex = events.findIndex((event) => event.type === 'tab-message');
assert.ok(readyIndex >= 0);
assert.ok(messageIndex > readyIndex);
assert.equal(events[messageIndex].message.type, 'PLUS_CHECKOUT_GET_STATE');
assert.deepEqual(events[messageIndex].message.payload, {
includeSession: true,
includeAccessToken: true,
});
assert.equal(fetchCalls.length, 1);
assert.equal(fetchCalls[0].url, 'https://gopay.hwork.pro/api/checkout/start');
const helperPayload = JSON.parse(fetchCalls[0].options.body);
assert.equal(helperPayload.customer_email, 'current.round+gpc@example.com');
assert.equal(helperPayload.card_key, 'card_test_123');
assert.deepEqual(helperPayload.gopay_link, {
type: 'gopay',
country_code: '86',
phone_number: '13800138000',
});
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.plusCheckoutSource, 'gpc-helper');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperReferenceId, 'ref_123');
assert.equal(events.find((event) => event.type === 'set-state')?.payload?.gopayHelperFlowId, 'flow_789');
assert.equal(events.find((event) => event.type === 'complete')?.step, 6);
assert.equal(events.find((event) => event.type === 'complete')?.payload?.plusCheckoutSource, 'gpc-helper');
});
test('GPC checkout treats non-zero API amount as non-free-trial and does not create order', async () => {
const markCalls = [];
const fetchCalls = [];
const events = [];
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async (message, level = 'info') => events.push({ type: 'log', message, level }),
chrome: {
tabs: {
create: async () => {
throw new Error('should not open token tab when direct access token exists');
},
remove: async () => {},
},
},
completeStepFromBackground: async () => {
throw new Error('should not complete step 6 for non-free-trial checkout');
},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async (url, options = {}) => {
fetchCalls.push({ url, options });
return {
ok: true,
status: 200,
json: async () => ({
reference_id: 'ref_paid',
gopay_guid: 'guid_paid',
next_action: 'enter_otp',
checkout: { amount_due: 'Rp 29.000' },
}),
};
},
markCurrentRegistrationAccountUsed: async (state, options) => {
markCalls.push({ state, options });
return { updated: true };
},
registerTab: async () => {},
sendTabMessageUntilStopped: async () => {},
setState: async () => {},
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await assert.rejects(
() => executor.executePlusCheckoutCreate({
email: 'paid@example.com',
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
chatgptAccessToken: 'state-access-token',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_paid_456',
}),
/PLUS_CHECKOUT_NON_FREE_TRIAL::.*余额非 0/
);
assert.equal(fetchCalls.length, 1);
assert.equal(JSON.parse(fetchCalls[0].options.body).card_key, 'card_paid_456');
assert.equal(markCalls.length, 1);
assert.equal(markCalls[0].state.email, 'paid@example.com');
assert.equal(markCalls[0].options.reason, 'plus-checkout-non-free-trial');
assert.equal(events.some((event) => event.type === 'log' && /订单已创建/.test(event.message)), false);
});
test('GPC checkout does not fall back to browser GoPay phone fields', async () => {
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async () => {},
chrome: {
tabs: {
create: async () => {
throw new Error('should not open token tab when direct access token exists');
},
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => {
throw new Error('should not call helper API without helper phone');
},
registerTab: async () => {},
sendTabMessageUntilStopped: async () => {},
setState: async () => {},
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await assert.rejects(
() => executor.executePlusCheckoutCreate({
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
chatgptAccessToken: 'state-access-token',
email: 'helper-phone-test@example.com',
gopayPhone: '+8613800138000',
gopayCountryCode: '+86',
gopayPin: '123456',
gopayHelperPhoneNumber: '',
gopayHelperPin: '123456',
gopayHelperCardKey: 'card_phone_test',
}),
/缺少手机号/
);
});
test('GPC checkout rejects missing card key before calling helper API', async () => {
const executor = api.createPlusCheckoutCreateExecutor({
addLog: async () => {},
chrome: {
tabs: {
create: async () => {
throw new Error('should not open token tab when direct access token exists');
},
remove: async () => {},
},
},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTabUntilStopped: async () => {},
fetch: async () => {
throw new Error('should not call helper API without card key');
},
registerTab: async () => {},
sendTabMessageUntilStopped: async () => {},
setState: async () => {},
sleepWithStop: async () => {},
waitForTabCompleteUntilStopped: async () => {},
});
await assert.rejects(
() => executor.executePlusCheckoutCreate({
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'https://gopay.hwork.pro/',
chatgptAccessToken: 'state-access-token',
email: 'missing-card@example.com',
gopayHelperPhoneNumber: '+8613800138000',
gopayHelperCountryCode: '+86',
gopayHelperPin: '123456',
gopayHelperCardKey: '',
}),
/缺少卡密/
);
});
+78
View File
@@ -157,6 +157,84 @@ return {
assert.equal(api.isDisabled(), true);
});
test('sidepanel pending auto-run start ignores stale active run count sync', () => {
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const bundle = [
extractFunction(source, 'hasOwnStateValue'),
extractFunction(source, 'readAutoRunStateValue'),
extractFunction(source, 'normalizePendingAutoRunStartRunCount'),
extractFunction(source, 'registerPendingAutoRunStartRunCount'),
extractFunction(source, 'clearPendingAutoRunStartRunCount'),
extractFunction(source, 'getPendingAutoRunStartRunCount'),
extractFunction(source, 'getAutoRunSourceTotalRuns'),
extractFunction(source, 'syncAutoRunState'),
extractFunction(source, 'isAutoRunLockedPhase'),
extractFunction(source, 'isAutoRunPausedPhase'),
extractFunction(source, 'isAutoRunScheduledPhase'),
extractFunction(source, 'isAutoRunSourceSyncPhase'),
extractFunction(source, 'shouldSyncRunCountFromAutoRunSource'),
extractFunction(source, 'applyAutoRunStatus'),
].join('\n');
const api = new Function(`
let currentAutoRun = {
autoRunning: false,
phase: 'idle',
currentRun: 0,
totalRuns: 1,
attemptRun: 0,
scheduledAt: null,
countdownAt: null,
countdownTitle: '',
countdownNote: '',
};
let pendingAutoRunStartTotalRuns = 0;
let pendingAutoRunStartExpiresAt = 0;
const inputRunCount = { value: '20', disabled: false };
const btnAutoRun = { disabled: false, innerHTML: '' };
const btnFetchEmail = { disabled: false };
const inputEmail = { disabled: false };
const inputAutoSkipFailures = { disabled: false };
const autoContinueBar = { style: { display: '' } };
function setSettingsCardLocked() {}
function getAutoRunLabel() { return ''; }
function getLockedRunCountFromEmailPool() { return 0; }
function isCustomMailProvider() { return false; }
function usesCustomEmailPoolGenerator() { return false; }
function setDefaultAutoRunButton() {}
function updateAutoDelayInputState() {}
function updateFallbackThreadIntervalInputState() {}
function syncScheduledCountdownTicker() {}
function updateStopButtonState() {}
function getStepStatuses() { return {}; }
function updateConfigMenuControls() {}
function renderContributionMode() {}
${bundle}
return {
applyAutoRunStatus,
registerPendingAutoRunStartRunCount,
getValue() {
return inputRunCount.value;
},
getPending() {
return pendingAutoRunStartTotalRuns;
},
};
`)();
api.registerPendingAutoRunStartRunCount(20);
api.applyAutoRunStatus({ autoRunning: true, autoRunPhase: 'running', autoRunTotalRuns: 1 });
assert.equal(api.getValue(), '20');
assert.equal(api.getPending(), 20);
api.applyAutoRunStatus({ autoRunning: true, autoRunPhase: 'running', autoRunTotalRuns: 20 });
assert.equal(api.getValue(), '20');
assert.equal(api.getPending(), 0);
});
test('background normalizeRunCount no longer clamps values to 50', () => {
const source = fs.readFileSync('background.js', 'utf8');
const bundle = extractFunction(source, 'normalizeRunCount');
+142 -1
View File
@@ -141,6 +141,13 @@ test('sidepanel html contains account records overlay and manager script', () =>
assert.match(html, /id="btn-toggle-account-records-selection"/);
assert.match(html, /id="btn-delete-selected-account-records"/);
assert.match(html, /id="input-sub2api-default-proxy"/);
assert.match(html, /src="editable-list-picker\.js"/);
assert.match(html, /id="sub2api-group-picker"/);
assert.match(html, /id="input-sub2api-group" value="codex"/);
assert.match(html, /id="btn-add-sub2api-group"/);
assert.match(html, /id="paypal-account-picker"/);
assert.match(html, /id="cf-domain-picker"/);
assert.match(html, /id="temp-email-domain-picker"/);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(managerIndex < sidepanelIndex);
@@ -336,7 +343,141 @@ test('account records manager supports filter chips and partial multi-select del
assert.match(list.innerHTML, /stopped@example\.com/);
assert.equal(btnDeleteSelectedAccountRecords.disabled, true);
assert.deepStrictEqual(toasts.at(-1), {
message: '已删除 1 条邮箱记录。',
message: '已删除 1 条账号记录。',
tone: 'success',
});
});
test('account records manager displays phone-only records with account identifier fallback', () => {
const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject);
const list = createNode();
const meta = createNode();
const manager = api.createAccountRecordsManager({
state: {
getLatestState: () => ({
accountRunHistory: [
{
recordId: 'phone:+6612345',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
phoneNumber: '+6612345',
email: '',
password: '',
finalStatus: 'success',
finishedAt: '2026-04-17T04:31:00.000Z',
retryCount: 0,
failureLabel: '流程完成',
},
],
}),
syncLatestState() {},
},
dom: {
accountRecordsList: list,
accountRecordsMeta: meta,
accountRecordsOverlay: createNode(),
accountRecordsPageLabel: createNode(),
accountRecordsStats: createNode(),
btnAccountRecordsNext: createNode(),
btnAccountRecordsPrev: createNode(),
btnClearAccountRecords: createNode(),
btnCloseAccountRecords: createNode(),
btnDeleteSelectedAccountRecords: createNode(),
btnOpenAccountRecords: createNode(),
btnToggleAccountRecordsSelection: createNode(),
},
helpers: {
escapeHtml: (value) => String(value || ''),
},
runtime: {
sendMessage: async () => ({}),
},
constants: {
displayTimeZone: 'Asia/Shanghai',
pageSize: 10,
},
});
manager.render();
assert.match(meta.textContent, /共 1 条/);
assert.match(list.innerHTML, /\+6612345/);
assert.doesNotMatch(list.innerHTML, /\(空邮箱\)/);
});
test('account records manager displays combined email and phone identities in one record', () => {
const source = fs.readFileSync('sidepanel/account-records-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelAccountRecordsManager;`)(windowObject);
const list = createNode();
const manager = api.createAccountRecordsManager({
state: {
getLatestState: () => ({
accountRunHistory: [
{
recordId: 'phone:+447700900123',
accountIdentifierType: 'phone',
accountIdentifier: '+447700900123',
phoneNumber: '+447700900123',
email: 'bound@example.com',
finalStatus: 'success',
finishedAt: '2026-04-17T04:31:00.000Z',
retryCount: 0,
failureLabel: '流程完成',
},
{
recordId: 'mail@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'mail@example.com',
phoneNumber: '447799342687',
email: 'mail@example.com',
finalStatus: 'failed',
finishedAt: '2026-04-17T04:30:00.000Z',
retryCount: 0,
failureLabel: '步骤 9 失败',
},
],
}),
syncLatestState() {},
},
dom: {
accountRecordsList: list,
accountRecordsMeta: createNode(),
accountRecordsOverlay: createNode(),
accountRecordsPageLabel: createNode(),
accountRecordsStats: createNode(),
btnAccountRecordsNext: createNode(),
btnAccountRecordsPrev: createNode(),
btnClearAccountRecords: createNode(),
btnCloseAccountRecords: createNode(),
btnDeleteSelectedAccountRecords: createNode(),
btnOpenAccountRecords: createNode(),
btnToggleAccountRecordsSelection: createNode(),
},
helpers: {
escapeHtml: (value) => String(value || ''),
},
runtime: {
sendMessage: async () => ({}),
},
constants: {
displayTimeZone: 'Asia/Shanghai',
pageSize: 10,
},
});
manager.render();
assert.match(list.innerHTML, /\+447700900123/);
assert.match(list.innerHTML, /邮箱 bound@example\.com/);
assert.match(list.innerHTML, /mail@example\.com/);
assert.match(list.innerHTML, /绑定手机号 447799342687/);
assert.match(
list.innerHTML,
/title="\+447700900123 \/ 邮箱 bound@example\.com"/
);
});
@@ -51,19 +51,18 @@ function extractFunction(name) {
function createApi({
refreshImpl,
runCount = 3,
plusModeEnabled = false,
plusRiskEnabled = false,
plusRiskConfirmed = true,
plusRiskDismissPrompt = false,
plusContributionImpl,
persistImpl,
} = {}) {
const bundle = extractFunction('startAutoRunFromCurrentSettings');
const bundle = [
extractFunction('normalizePendingAutoRunStartRunCount'),
extractFunction('registerPendingAutoRunStartRunCount'),
extractFunction('clearPendingAutoRunStartRunCount'),
extractFunction('startAutoRunFromCurrentSettings'),
].join('\n');
return new Function(`
const events = [];
const latestState = { contributionMode: false };
const currentPlusModeEnabled = ${JSON.stringify(Boolean(plusModeEnabled))};
const inputPlusModeEnabled = { checked: ${JSON.stringify(Boolean(plusModeEnabled))} };
const inputAutoSkipFailures = { checked: false };
const inputContributionNickname = { value: 'tester' };
const inputContributionQq = { value: '123456' };
@@ -72,6 +71,9 @@ const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const btnAutoRun = { disabled: false, innerHTML: '' };
const inputRunCount = { disabled: false };
let runCountValue = ${Math.max(1, Number(runCount) || 1)};
let pendingAutoRunStartTotalRuns = 0;
let pendingAutoRunStartExpiresAt = 0;
const chrome = {
runtime: {
async sendMessage(message) {
@@ -87,8 +89,16 @@ const console = {
};
async function persistCurrentSettingsForAction() {
events.push({ type: 'sync-settings' });
${persistImpl ? `return (${persistImpl})(events, {
setRunCount(value) {
runCountValue = Math.max(1, Number(value) || 1);
},
getRunCount() {
return runCountValue;
},
});` : ''}
}
function getRunCountValue() { return ${Math.max(1, Number(runCount) || 1)}; }
function getRunCountValue() { return Math.max(1, Number(runCountValue) || 1); }
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
function shouldOfferAutoModeChoice() { return false; }
async function openAutoStartChoiceDialog() { throw new Error('should not be called'); }
@@ -98,23 +108,6 @@ function shouldWarnAutoRunFallbackRisk() { return false; }
function isAutoRunFallbackRiskPromptDismissed() { return false; }
async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); }
function setAutoRunFallbackRiskPromptDismissed() {}
function shouldWarnPlusAutoRunRisk(totalRuns, plusModeEnabled) {
return ${JSON.stringify(Boolean(plusRiskEnabled))} && Boolean(plusModeEnabled) && Number(totalRuns) > 3;
}
function isAutoRunPlusRiskPromptDismissed() { return false; }
async function openPlusAutoRunRiskConfirmModal(totalRuns) {
events.push({ type: 'plus-risk-modal', totalRuns });
return {
confirmed: ${JSON.stringify(Boolean(plusRiskConfirmed))},
dismissPrompt: ${JSON.stringify(Boolean(plusRiskDismissPrompt))},
};
}
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' });
@@ -177,11 +170,13 @@ test('startAutoRunFromCurrentSettings does not block auto run when contribution
);
});
test('startAutoRunFromCurrentSettings shows Plus risk warning before starting more than 3 runs', async () => {
test('startAutoRunFromCurrentSettings freezes run count before async settings sync can repaint it', async () => {
const api = createApi({
runCount: 4,
plusModeEnabled: true,
plusRiskEnabled: true,
runCount: 20,
persistImpl: `(events, controls) => {
controls.setRunCount(1);
events.push({ type: 'stale-status-reset', runCount: controls.getRunCount() });
}`,
});
const result = await api.startAutoRunFromCurrentSettings();
@@ -190,46 +185,9 @@ test('startAutoRunFromCurrentSettings shows Plus risk warning before starting mo
assert.equal(result, true);
assert.deepEqual(
events.map((entry) => entry.type),
['refresh', 'sync-settings', 'plus-risk-modal', 'send']
['refresh', 'sync-settings', 'stale-status-reset', 'send']
);
assert.equal(events[2].totalRuns, 4);
assert.equal(events[3].message.payload.totalRuns, 4);
});
test('startAutoRunFromCurrentSettings aborts when Plus risk warning is declined', async () => {
const api = createApi({
runCount: 4,
plusModeEnabled: true,
plusRiskEnabled: true,
plusRiskConfirmed: false,
});
const result = await api.startAutoRunFromCurrentSettings();
assert.equal(result, false);
assert.deepEqual(
api.getEvents().map((entry) => entry.type),
['refresh', 'sync-settings', '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', 'sync-settings', 'plus-contribution-modal']
);
assert.equal(api.getEvents()[2].plusModeEnabled, true);
assert.equal(events[3].message.payload.totalRuns, 20);
});
test('persistCurrentSettingsForAction forces a silent save even when settings are not marked dirty', async () => {
@@ -245,10 +203,14 @@ let clearedTimer = null;
let settingsSaveInFlight = false;
let settingsDirty = false;
let settingsSaveRevision = 0;
let phonePersistCalls = 0;
const saveCalls = [];
function clearTimeout(value) {
clearedTimer = value;
}
async function persistSignupPhoneInputForAction() {
phonePersistCalls += 1;
}
function updateSaveButtonState() {}
function collectSettingsPayload() {
return { luckmailApiKey: 'autofilled-key' };
@@ -271,7 +233,7 @@ ${bundle}
return {
persistCurrentSettingsForAction,
getSnapshot() {
return { clearedTimer, saveCalls };
return { clearedTimer, phonePersistCalls, saveCalls };
},
};
`)();
@@ -280,5 +242,6 @@ return {
const snapshot = api.getSnapshot();
assert.equal(snapshot.clearedTimer, 123);
assert.equal(snapshot.phonePersistCalls, 1);
assert.deepStrictEqual(snapshot.saveCalls, [{ luckmailApiKey: 'autofilled-key' }]);
});
@@ -65,48 +65,6 @@ return { shouldWarnAutoRunFallbackRisk };
assert.equal(api.shouldWarnAutoRunFallbackRisk(10, true), true);
});
test('Plus auto-run risk warning only starts above 3 runs in Plus mode', () => {
const bundle = extractFunction('shouldWarnPlusAutoRunRisk');
const api = new Function(`
const AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3;
${bundle}
return { shouldWarnPlusAutoRunRisk };
`)();
assert.equal(api.shouldWarnPlusAutoRunRisk(3, true), false);
assert.equal(api.shouldWarnPlusAutoRunRisk(4, true), true);
assert.equal(api.shouldWarnPlusAutoRunRisk(10, false), false);
});
test('Plus auto-run risk modal uses PayPal account warning copy', async () => {
const bundle = extractFunction('openPlusAutoRunRiskConfirmModal');
const api = new Function(`
let capturedOptions = null;
async function openConfirmModalWithOption(options) {
capturedOptions = options;
return { confirmed: true, optionChecked: true };
}
${bundle}
return {
openPlusAutoRunRiskConfirmModal,
getCapturedOptions() {
return capturedOptions;
},
};
`)();
const result = await api.openPlusAutoRunRiskConfirmModal(4);
const options = api.getCapturedOptions();
assert.deepStrictEqual(result, { confirmed: true, dismissPrompt: true });
assert.equal(options.title, 'Plus 自动轮数提醒');
assert.match(options.message, /轮数过多可能造成 PayPal 或账号快速封号/);
assert.match(options.message, /不要贪杯哦/);
assert.equal(options.confirmLabel, '我知道了,继续');
});
test('auto-run fallback risk modal uses the single-node warning copy', async () => {
const bundle = extractFunction('openAutoRunFallbackRiskConfirmModal');
+62
View File
@@ -180,11 +180,13 @@ test('collectSettingsPayload omits custom password and local sync settings in co
const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', `
let latestState = { contributionMode: true };
const window = {};
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: 'example.com' };
const selectTempEmailDomain = { value: 'mail.example.com' };
const selectPanelMode = { value: 'cpa' };
function getSelectedPlusPaymentMethod() { return 'paypal'; }
const inputVpsUrl = { value: 'https://panel.example.com' };
const inputVpsPassword = { value: 'panel-secret' };
const inputSub2ApiUrl = { value: 'https://sub.example.com' };
@@ -200,6 +202,17 @@ const selectEmailGenerator = { value: 'duck' };
const checkboxAutoDeleteIcloud = { checked: true };
const selectIcloudHostPreference = { value: 'auto' };
const inputPhoneVerificationEnabled = { checked: true };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'country' };
const inputHeroSmsMaxPrice = { value: '' };
const inputFiveSimOperator = { value: 'any' };
const inputPhoneReplacementLimit = { value: '3' };
const inputPhoneCodeWaitSeconds = { value: '60' };
const inputPhoneCodeTimeoutWindows = { value: '2' };
const inputPhoneCodePollIntervalSeconds = { value: '5' };
const inputPhoneCodePollMaxRounds = { value: '4' };
const inputAccountRunHistoryTextEnabled = { checked: true };
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
const inputInbucketHost = { value: 'inbucket.local' };
@@ -223,7 +236,35 @@ const inputAutoStepDelaySeconds = { value: '10' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '6' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; }
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
@@ -242,6 +283,27 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_FIVE_SIM_COUNTRY_LABEL) { return String(value || '').trim() || fallback; }
function normalizeFiveSimOperator(value = '', fallback = DEFAULT_FIVE_SIM_OPERATOR) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizeFiveSimCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeFiveSimCountryId(entry?.id ?? entry, ''), label: String(entry?.label || entry?.id || entry || '').trim() })).filter((entry) => entry.id) : []; }
function normalizeHeroSmsMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizePhoneSmsMaxPriceValue(value = '', provider = getSelectedPhoneSmsProvider()) { return normalizePhoneSmsProvider(provider) === '5sim' ? normalizeFiveSimMaxPriceValue(value) : normalizeHeroSmsMaxPriceValue(value); }
function normalizeHeroSmsReuseEnabledValue(value) { return value === undefined || value === null ? true : Boolean(value); }
function normalizeHeroSmsAcquirePriority(value = '') { return String(value || '').trim().toLowerCase() === 'price' ? 'price' : 'country'; }
function normalizeHeroSmsCountryId(value) { return Math.max(1, Math.floor(Number(value) || 52)); }
function normalizeHeroSmsCountryLabel(value = '') { return String(value || '').trim() || 'Thailand'; }
function normalizeHeroSmsCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeHeroSmsCountryId(entry?.id ?? entry), label: String(entry?.label || 'Thailand') })) : []; }
function normalizePhoneVerificationReplacementLimit(value, fallback = 3) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodeWaitSecondsValue(value, fallback = 60) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodeTimeoutWindowsValue(value, fallback = 2) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollIntervalSecondsValue(value, fallback = 5) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollMaxRoundsValue(value, fallback = 4) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function getSelectedHeroSmsCountryOption() { return { id: 52, label: 'Thailand' }; }
function syncHeroSmsFallbackSelectionOrderFromSelect() { return [{ id: 52, label: 'Thailand' }]; }
${bundle}
return {
collectSettingsPayload,
+12
View File
@@ -57,6 +57,9 @@ test('sidepanel html exposes custom email pool generator option and input row',
assert.match(html, /option value="custom-pool">自定义邮箱池<\/option>/);
assert.match(html, /id="row-custom-email-pool"/);
assert.match(html, /id="input-custom-email-pool"/);
assert.match(html, /id="input-custom-email-pool-import"/);
assert.match(html, /id="custom-email-pool-list"/);
assert.match(html, /id="btn-custom-email-pool-bulk-used"/);
assert.match(html, /id="row-custom-mail-provider-pool"/);
assert.match(html, /id="input-custom-mail-provider-pool"/);
});
@@ -175,6 +178,15 @@ return {
assert.equal(api.getRunCountValue(), 3);
});
test('sidepanel queues custom email pool refresh when the pool row is visible', () => {
const source = extractFunction('updateMailProviderUI');
assert.match(
source,
/if \(useCustomEmailPool\) \{\s*syncRunCountFromCustomEmailPool\(\);\s*if \(typeof queueCustomEmailPoolRefresh === 'function'\) \{\s*queueCustomEmailPoolRefresh\(\);\s*\}\s*\}/
);
});
test('sidepanel custom verification dialog exposes add-phone action for step 8', async () => {
const bundle = [
extractFunction('getCustomVerificationPromptCopy'),
+3
View File
@@ -23,6 +23,9 @@ test('update card highlights exporting config before upgrade', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
assert.match(html, /id="btn-ignore-release"/);
assert.match(html, /class="update-card-actions"/);
assert.match(css, /\.update-card-actions\s*\{/);
assert.match(html, /<p class="update-card-reminder">一定请先导出配置,再执行更新<\/p>/);
assert.match(css, /\.update-card-reminder\s*\{/);
assert.match(css, /font-weight:\s*700;/);
+103 -14
View File
@@ -85,11 +85,13 @@ test('collectSettingsPayload persists icloud target mailbox settings', () => {
const api = new Function(`
let latestState = { contributionMode: false };
const window = {};
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: '' };
const selectTempEmailDomain = { value: '' };
const selectPanelMode = { value: 'cpa' };
function getSelectedPlusPaymentMethod() { return 'paypal'; }
const inputVpsUrl = { value: '' };
const inputVpsPassword = { value: '' };
const inputSub2ApiUrl = { value: '' };
@@ -108,6 +110,8 @@ const selectIcloudFetchMode = { value: 'reuse_existing' };
const selectIcloudTargetMailboxType = { value: 'forward-mailbox' };
const selectIcloudForwardMailProvider = { value: 'gmail' };
const inputPhoneVerificationEnabled = { checked: false };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const inputFiveSimOperator = { value: 'any' };
const inputAccountRunHistoryTextEnabled = { checked: false };
const inputAccountRunHistoryHelperBaseUrl = { value: '' };
const inputInbucketHost = { value: '' };
@@ -131,25 +135,35 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'country' };
@@ -206,6 +220,20 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function setPhoneSmsProviderSelectValue(provider) {
const normalizedProvider = normalizePhoneSmsProvider(provider);
selectPhoneSmsProvider.value = normalizedProvider;
return normalizedProvider;
}
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_FIVE_SIM_COUNTRY_LABEL) { return String(value || '').trim() || fallback; }
function normalizeFiveSimOperator(value = '', fallback = DEFAULT_FIVE_SIM_OPERATOR) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizeFiveSimCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeFiveSimCountryId(entry?.id ?? entry, ''), label: String(entry?.label || entry?.id || entry || '').trim() })).filter((entry) => entry.id) : []; }
function normalizePhoneSmsMaxPriceValue(value = '', provider = getSelectedPhoneSmsProvider()) { return normalizePhoneSmsProvider(provider) === '5sim' ? normalizeFiveSimMaxPriceValue(value) : normalizeHeroSmsMaxPriceValue(value); }
function normalizeHeroSmsCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeHeroSmsCountryId(entry?.id ?? entry), label: String(entry?.label || 'Thailand') })) : []; }
function normalizeIcloudTargetMailboxType(value) { return String(value || '').trim().toLowerCase() === 'forward-mailbox' ? 'forward-mailbox' : 'icloud-inbox'; }
function normalizeIcloudForwardMailProvider(value) { return String(value || '').trim().toLowerCase() === 'gmail' ? 'gmail' : 'qq'; }
${bundle}
@@ -379,21 +407,59 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'country' };
const inputHeroSmsMaxPrice = { value: '' };
const inputFiveSimOperator = { value: 'any' };
const inputPhoneReplacementLimit = { value: '' };
const inputPhoneCodeWaitSeconds = { value: '' };
const inputPhoneCodeTimeoutWindows = { value: '' };
const inputPhoneCodePollIntervalSeconds = { value: '' };
const inputPhoneCodePollMaxRounds = { value: '' };
const selectHeroSmsCountry = { value: '52', options: [{ value: '52' }] };
const inputRunCount = { value: '' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
function syncLatestState(state) { latestState = { ...latestState, ...state }; }
function syncAutoRunState() {}
function syncPasswordField() {}
function renderStepStatuses() {}
function setLocalCpaStep9Mode() {}
function normalizePanelMode(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'sub2api' || normalized === 'codex2api' ? normalized : 'cpa';
}
function isCustomMailProvider() { return false; }
function setMail2925Mode() {}
function normalizeIcloudFetchMode(value) { return String(value || '') === 'always_new' ? 'always_new' : 'reuse_existing'; }
@@ -418,9 +484,31 @@ function normalizePhoneVerificationReplacementLimit(value, fallback = 3) {
if (!Number.isFinite(numeric)) return fallback;
return Math.max(PHONE_REPLACEMENT_LIMIT_MIN, Math.min(PHONE_REPLACEMENT_LIMIT_MAX, Math.floor(numeric)));
}
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function setPhoneSmsProviderSelectValue(provider) {
const normalizedProvider = normalizePhoneSmsProvider(provider);
selectPhoneSmsProvider.value = normalizedProvider;
return normalizedProvider;
}
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_FIVE_SIM_COUNTRY_LABEL) { return String(value || '').trim() || fallback; }
function normalizeFiveSimOperator(value = '', fallback = DEFAULT_FIVE_SIM_OPERATOR) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizeFiveSimCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeFiveSimCountryId(entry?.id ?? entry, ''), label: String(entry?.label || entry?.id || entry || '').trim() })).filter((entry) => entry.id) : []; }
function normalizeHeroSmsCountryId() { return 52; }
function getSelectedHeroSmsCountryOption() { return { label: 'Thailand' }; }
function normalizeHeroSmsCountryLabel(value = '') { return String(value || '').trim() || 'Thailand'; }
function normalizeHeroSmsCountryFallbackList(value = []) { return Array.isArray(value) ? value : []; }
function normalizeHeroSmsReuseEnabledValue(value) { return value === undefined || value === null ? true : Boolean(value); }
function normalizeHeroSmsAcquirePriority(value = '') { return String(value || '').trim().toLowerCase() === 'price' ? 'price' : 'country'; }
function normalizePhoneCodeWaitSecondsValue(value, fallback = 60) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodeTimeoutWindowsValue(value, fallback = 2) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollIntervalSecondsValue(value, fallback = 5) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollMaxRoundsValue(value, fallback = 4) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function getSelectedHeroSmsCountryOption() { return { id: 52, label: 'Thailand' }; }
function applyHeroSmsFallbackSelection() {}
function updateHeroSmsPlatformDisplay() {}
function updatePhoneSmsProviderOrderSummary() {}
function applyAutoRunStatus() {}
function markSettingsDirty() {}
function updateAutoDelayInputState() {}
@@ -429,6 +517,7 @@ function updateAccountRunHistorySettingsUI() {}
function updatePhoneVerificationSettingsUI() {}
function updatePanelModeUI() {}
function updateMailProviderUI() { calls.push({ target: selectIcloudTargetMailboxType.value, provider: selectIcloudForwardMailProvider.value }); }
function renderSub2ApiGroupOptions() {}
function isLuckmailProvider() { return false; }
function updateButtonStates() {}
${bundle}
@@ -158,11 +158,13 @@ let latestState = {
mail2925UseAccountPool: true,
currentMail2925AccountId: 'acc-2',
};
const window = {};
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
const selectCfDomain = { value: 'example.com' };
const selectTempEmailDomain = { value: 'mail.example.com' };
const selectPanelMode = { value: 'cpa' };
function getSelectedPlusPaymentMethod() { return 'paypal'; }
const inputVpsUrl = { value: '' };
const inputVpsPassword = { value: '' };
const inputSub2ApiUrl = { value: '' };
@@ -178,6 +180,8 @@ const selectEmailGenerator = { value: 'duck' };
const checkboxAutoDeleteIcloud = { checked: false };
const selectIcloudHostPreference = { value: 'auto' };
const inputPhoneVerificationEnabled = { checked: false };
const selectPhoneSmsProvider = { value: 'hero-sms' };
const inputFiveSimOperator = { value: 'any' };
const inputAccountRunHistoryTextEnabled = { checked: false };
const inputAccountRunHistoryHelperBaseUrl = { value: '' };
const inputMail2925UseAccountPool = { checked: true };
@@ -202,6 +206,34 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputOAuthFlowTimeoutEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const DEFAULT_HERO_SMS_REUSE_ENABLED = true;
const HERO_SMS_ACQUIRE_PRIORITY_COUNTRY = 'country';
const HERO_SMS_ACQUIRE_PRIORITY_PRICE = 'price';
const DEFAULT_HERO_SMS_ACQUIRE_PRIORITY = HERO_SMS_ACQUIRE_PRIORITY_COUNTRY;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const PHONE_CODE_WAIT_SECONDS_MIN = 15;
const PHONE_CODE_WAIT_SECONDS_MAX = 300;
const DEFAULT_PHONE_CODE_WAIT_SECONDS = 60;
const PHONE_CODE_TIMEOUT_WINDOWS_MIN = 1;
const PHONE_CODE_TIMEOUT_WINDOWS_MAX = 10;
const DEFAULT_PHONE_CODE_TIMEOUT_WINDOWS = 2;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MIN = 1;
const PHONE_CODE_POLL_INTERVAL_SECONDS_MAX = 30;
const DEFAULT_PHONE_CODE_POLL_INTERVAL_SECONDS = 5;
const PHONE_CODE_POLL_MAX_ROUNDS_MIN = 1;
const PHONE_CODE_POLL_MAX_ROUNDS_MAX = 120;
const DEFAULT_PHONE_CODE_POLL_MAX_ROUNDS = 4;
const DEFAULT_PHONE_VERIFICATION_REPLACEMENT_LIMIT = 3;
function getCloudflareDomainsFromState() {
return { domains: [], activeDomain: '' };
@@ -226,6 +258,27 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizePhoneSmsProvider(value = '') { return String(value || '').trim().toLowerCase() === '5sim' ? '5sim' : 'hero-sms'; }
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function normalizeFiveSimCountryId(value, fallback = DEFAULT_FIVE_SIM_COUNTRY_ID) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_FIVE_SIM_COUNTRY_LABEL) { return String(value || '').trim() || fallback; }
function normalizeFiveSimOperator(value = '', fallback = DEFAULT_FIVE_SIM_OPERATOR) { return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || fallback; }
function normalizeFiveSimMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizeFiveSimCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeFiveSimCountryId(entry?.id ?? entry, ''), label: String(entry?.label || entry?.id || entry || '').trim() })).filter((entry) => entry.id) : []; }
function normalizeHeroSmsMaxPriceValue(value = '') { const numeric = Number(String(value ?? '').trim()); return Number.isFinite(numeric) && numeric > 0 ? String(Math.round(numeric * 10000) / 10000) : ''; }
function normalizePhoneSmsMaxPriceValue(value = '', provider = getSelectedPhoneSmsProvider()) { return normalizePhoneSmsProvider(provider) === '5sim' ? normalizeFiveSimMaxPriceValue(value) : normalizeHeroSmsMaxPriceValue(value); }
function normalizeHeroSmsReuseEnabledValue(value) { return value === undefined || value === null ? true : Boolean(value); }
function normalizeHeroSmsAcquirePriority(value = '') { return String(value || '').trim().toLowerCase() === 'price' ? 'price' : 'country'; }
function normalizeHeroSmsCountryId(value) { return Math.max(1, Math.floor(Number(value) || 52)); }
function normalizeHeroSmsCountryLabel(value = '') { return String(value || '').trim() || 'Thailand'; }
function normalizeHeroSmsCountryFallbackList(value = []) { return Array.isArray(value) ? value.map((entry) => ({ id: normalizeHeroSmsCountryId(entry?.id ?? entry), label: String(entry?.label || 'Thailand') })) : []; }
function normalizePhoneVerificationReplacementLimit(value, fallback = 3) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodeWaitSecondsValue(value, fallback = 60) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodeTimeoutWindowsValue(value, fallback = 2) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollIntervalSecondsValue(value, fallback = 5) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function normalizePhoneCodePollMaxRoundsValue(value, fallback = 4) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : fallback; }
function getSelectedHeroSmsCountryOption() { return { id: 52, label: 'Thailand' }; }
function syncHeroSmsFallbackSelectionOrderFromSelect() { return [{ id: 52, label: 'Thailand' }]; }
${bundle}
return { collectSettingsPayload };
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
+101 -2
View File
@@ -5,22 +5,36 @@ const fs = require('node:fs');
test('sidepanel loads reusable form dialog and paypal manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const formDialogIndex = html.indexOf('<script src="form-dialog.js"></script>');
const editableListPickerIndex = html.indexOf('<script src="editable-list-picker.js"></script>');
const managerIndex = html.indexOf('<script src="paypal-manager.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.notEqual(formDialogIndex, -1);
assert.notEqual(editableListPickerIndex, -1);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(formDialogIndex < managerIndex);
assert.ok(formDialogIndex < editableListPickerIndex);
assert.ok(editableListPickerIndex < managerIndex);
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
test('sidepanel html contains paypal select and GoPay controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-plus-payment-method"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="paypal-account-picker"/);
assert.match(html, /id="btn-paypal-account-menu"/);
assert.match(html, /id="paypal-account-menu"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="row-gopay-phone"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="row-gopay-otp"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="row-gopay-pin"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /id="shared-form-modal"/);
});
@@ -131,3 +145,88 @@ test('paypal manager saves a paypal account and selects it immediately', async (
assert.equal(selectNode.disabled, false);
assert.match(events.at(-1)?.message || '', /已保存 PayPal 账号/);
});
test('paypal manager uses editable picker and deletes obsolete account', async () => {
const source = fs.readFileSync('sidepanel/paypal-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelPayPalManager;`)(windowObject);
let latestState = {
paypalAccounts: [
{ id: 'pp-1', email: 'old@example.com', password: 'old-secret' },
{ id: 'pp-2', email: 'next@example.com', password: 'next-secret' },
],
currentPayPalAccountId: 'pp-1',
paypalEmail: 'old@example.com',
paypalPassword: 'old-secret',
};
const events = [];
const renderCalls = [];
let pickerConfig = null;
const selectNode = {
value: '',
addEventListener() {},
};
const manager = api.createPayPalManager({
state: {
getLatestState: () => latestState,
syncLatestState(updates) {
latestState = { ...latestState, ...updates };
},
},
dom: {
btnAddPayPalAccount: { disabled: false, addEventListener() {} },
btnPayPalAccountMenu: {},
payPalAccountCurrent: {},
payPalAccountMenu: {},
payPalAccountPickerRoot: {},
selectPayPalAccount: selectNode,
},
helpers: {
editableListPicker: {
createEditableListPicker(config) {
pickerConfig = config;
return {
render(items, selectedValue) {
renderCalls.push({ items, selectedValue });
selectNode.value = selectedValue;
},
};
},
},
escapeHtml: (value) => String(value || ''),
getPayPalAccounts: (state) => Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [],
openFormDialog: async () => null,
showToast(message, tone) {
events.push({ type: 'toast', message, tone });
},
},
runtime: {
sendMessage: async (message) => {
events.push({ type: 'message', message });
if (message.type === 'SAVE_SETTING') {
return { ok: true };
}
throw new Error(`unexpected message ${message.type}`);
},
},
});
manager.renderPayPalAccounts();
assert.equal(renderCalls.at(-1).selectedValue, 'pp-1');
assert.equal(pickerConfig.getItemLabel(latestState.paypalAccounts[0]), 'old@example.com');
await pickerConfig.onDelete('pp-1');
const saveMessage = events.find((event) => event.type === 'message')?.message;
assert.equal(saveMessage.type, 'SAVE_SETTING');
assert.deepEqual(
saveMessage.payload.paypalAccounts.map((account) => account.id),
['pp-2']
);
assert.equal(saveMessage.payload.currentPayPalAccountId, 'pp-2');
assert.equal(latestState.currentPayPalAccountId, 'pp-2');
assert.equal(latestState.paypalEmail, 'next@example.com');
assert.match(events.at(-1)?.message || '', /已删除 PayPal 账号:old@example\.com/);
});
@@ -52,13 +52,30 @@ function extractFunction(name) {
return sidepanelSource.slice(start, end);
}
test('sidepanel html exposes phone verification toggle and dedicated HeroSMS rows', () => {
test('sidepanel html exposes phone verification toggle and multi-provider SMS rows', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-phone-verification-enabled"/);
assert.match(html, /id="btn-toggle-phone-verification-section"/);
assert.match(html, /id="row-phone-verification-fold"/);
assert.match(html, /id="input-phone-verification-enabled"/);
assert.match(html, /id="row-signup-method"/);
assert.match(html, /id="row-signup-phone"/);
assert.match(html, /id="input-signup-phone"/);
assert.ok(
html.indexOf('id="row-signup-phone"') > html.indexOf('id="phone-verification-section"'),
'signup phone runtime row should live inside the phone verification card'
);
assert.ok(
html.indexOf('id="row-signup-phone"') > html.indexOf('id="row-hero-sms-runtime-pair"'),
'signup phone runtime row should sit below the SMS order runtime row'
);
assert.ok(
html.indexOf('id="row-signup-phone"') > html.indexOf('hero-sms-runtime-grid'),
'signup phone runtime row should not be embedded in the SMS order runtime grid'
);
assert.match(html, /data-signup-method="email"/);
assert.match(html, /data-signup-method="phone"/);
assert.match(html, /id="row-phone-sms-provider"/);
assert.match(html, /id="select-phone-sms-provider"/);
assert.match(html, /id="row-phone-sms-provider-order"/);
@@ -67,6 +84,12 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-phone-sms-provider-order-actions"/);
assert.match(html, /id="btn-phone-sms-provider-order-reset"/);
assert.match(html, /id="row-hero-sms-platform"/);
assert.match(html, /id="select-phone-sms-provider"/);
assert.match(html, /\.\.\/phone-sms\/providers\/hero-sms\.js/);
assert.match(html, /\.\.\/phone-sms\/providers\/five-sim\.js/);
assert.match(html, /\.\.\/phone-sms\/providers\/registry\.js/);
assert.match(html, /<option value="hero-sms">HeroSMS<\/option>/);
assert.match(html, /<option value="5sim">5sim<\/option>/);
assert.match(html, /id="row-hero-sms-country"/);
assert.match(html, /id="row-hero-sms-country-fallback"/);
assert.match(html, /id="row-hero-sms-acquire-priority"/);
@@ -75,6 +98,10 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.doesNotMatch(html, /id="select-hero-sms-country-fallback"/);
assert.match(html, /id="row-hero-sms-api-key"/);
assert.match(html, /id="row-hero-sms-max-price"/);
assert.match(html, /id="btn-phone-sms-balance"/);
assert.match(html, /id="display-phone-sms-balance"/);
assert.match(html, /id="row-five-sim-operator"/);
assert.match(html, /id="input-five-sim-operator"/);
assert.match(html, /id="row-hero-sms-current-number"/);
assert.match(html, /id="row-hero-sms-current-countdown"/);
assert.match(html, /id="row-hero-sms-price-tiers"/);
@@ -107,13 +134,288 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
test('sidepanel source wires runtime signup phone field to background sync messages', () => {
assert.match(sidepanelSource, /function getRuntimeSignupPhoneValue\(state = latestState\)/);
assert.match(sidepanelSource, /function shouldExecuteStep3WithSignupPhoneIdentity\(state = latestState\)/);
assert.match(sidepanelSource, /function shouldPreserveSignupPhoneInputValue\(stateSignupPhone = ''\)/);
assert.match(sidepanelSource, /function syncSignupPhoneInputFromState\(state = latestState\)/);
assert.match(sidepanelSource, /async function persistSignupPhoneInputForAction\(\)/);
assert.match(sidepanelSource, /type:\s*'SET_SIGNUP_PHONE_STATE'/);
assert.match(sidepanelSource, /final \? 'SAVE_SIGNUP_PHONE' : 'SET_SIGNUP_PHONE_STATE'/);
assert.match(sidepanelSource, /message\.payload\.signupPhoneNumber !== undefined/);
assert.match(sidepanelSource, /await persistSignupPhoneInputForAction\(\);\s*await saveSettings/);
assert.match(sidepanelSource, /if \(shouldExecuteStep3WithSignupPhoneIdentity\(latestState\)\)[\s\S]*payload: \{ step \}/);
assert.match(sidepanelSource, /async function handleSkipStep\(step\)[\s\S]*await persistCurrentSettingsForAction\(\);/);
assert.match(sidepanelSource, /inputSignupPhone\.addEventListener\('input'[\s\S]*signupPhoneInputDirty = true/);
});
test('sidepanel warns once before using phone signup with CPA source', async () => {
assert.match(
sidepanelSource,
/signupMethodButtons\.forEach\(\(button\) => \{[\s\S]*await confirmCpaPhoneSignupIfNeeded\(\{[\s\S]*signupMethod: nextSignupMethod,[\s\S]*panelMode: getSelectedPanelMode\(\),/
);
assert.match(
sidepanelSource,
/selectPanelMode\.addEventListener\('change', async \(\) => \{[\s\S]*await confirmCpaPhoneSignupIfNeeded\(\{[\s\S]*signupMethod: getSelectedSignupMethod\(\),[\s\S]*panelMode: nextPanelMode,/
);
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePanelMode'),
extractFunction('isPromptDismissed'),
extractFunction('setPromptDismissed'),
extractFunction('isCpaPhoneSignupPromptDismissed'),
extractFunction('setCpaPhoneSignupPromptDismissed'),
extractFunction('shouldWarnCpaPhoneSignup'),
extractFunction('openCpaPhoneSignupWarningModal'),
extractFunction('confirmCpaPhoneSignupIfNeeded'),
].join('\n');
const api = new Function(`
const phoneVerificationSectionExpanded = true;
const SIGNUP_METHOD_PHONE = 'phone';
const SIGNUP_METHOD_EMAIL = 'email';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const CPA_PHONE_SIGNUP_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-cpa-phone-signup-prompt-dismissed';
const CPA_PHONE_SIGNUP_WARNING_MESSAGE = 'CPA 未适配手机号注册模式,认证成功后无法使用。请使用 SUB2API,或者认证成功后重新登录一遍进行解决。';
const storage = new Map();
const localStorage = {
getItem(key) {
return storage.has(key) ? storage.get(key) : null;
},
setItem(key, value) {
storage.set(key, String(value));
},
removeItem(key) {
storage.delete(key);
},
};
let selectedSignupMethod = 'phone';
let selectedPanelMode = 'cpa';
let capturedOptions = null;
let modalResult = { confirmed: true, optionChecked: false };
function getSelectedSignupMethod() {
return selectedSignupMethod;
}
function getSelectedPanelMode() {
return selectedPanelMode;
}
async function openConfirmModalWithOption(options) {
capturedOptions = options;
return modalResult;
}
${bundle}
return {
shouldWarnCpaPhoneSignup,
confirmCpaPhoneSignupIfNeeded,
getCapturedOptions() {
return capturedOptions;
},
getDismissed() {
return localStorage.getItem(CPA_PHONE_SIGNUP_PROMPT_DISMISSED_STORAGE_KEY);
},
setModalResult(result) {
modalResult = result;
},
};
`)();
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), true);
assert.equal(api.shouldWarnCpaPhoneSignup('email', 'cpa'), false);
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'sub2api'), false);
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'codex2api'), false);
const firstResult = await api.confirmCpaPhoneSignupIfNeeded({ signupMethod: 'phone', panelMode: 'cpa' });
assert.equal(firstResult, true);
assert.equal(api.getCapturedOptions().title, 'CPA 手机号注册提醒');
assert.equal(api.getCapturedOptions().message, 'CPA 未适配手机号注册模式,认证成功后无法使用。请使用 SUB2API,或者认证成功后重新登录一遍进行解决。');
assert.equal(api.getCapturedOptions().confirmLabel, '继续');
assert.equal(api.getCapturedOptions().optionLabel, '不再提醒');
assert.equal(api.getDismissed(), null);
api.setModalResult({ confirmed: false, optionChecked: true });
const secondResult = await api.confirmCpaPhoneSignupIfNeeded({ signupMethod: 'phone', panelMode: 'cpa' });
assert.equal(secondResult, false);
assert.equal(api.getDismissed(), '1');
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false);
});
test('manual step 3 uses phone identity without requiring registration email', () => {
const api = new Function(`
let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111', accountIdentifierType: 'phone', accountIdentifier: '+441111111111' };
const DEFAULT_SIGNUP_METHOD = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
function getSelectedSignupMethod() { return 'phone'; }
${extractFunction('normalizeSignupMethod')}
${extractFunction('getRuntimeSignupPhoneValue')}
${extractFunction('shouldExecuteStep3WithSignupPhoneIdentity')}
return { shouldExecuteStep3WithSignupPhoneIdentity };
`)();
assert.equal(api.shouldExecuteStep3WithSignupPhoneIdentity({
signupMethod: 'phone',
phoneVerificationEnabled: true,
accountIdentifierType: 'phone',
accountIdentifier: '+441111111111',
signupPhoneNumber: '+441111111111',
email: '',
}), true);
assert.equal(api.shouldExecuteStep3WithSignupPhoneIdentity({
signupMethod: 'email',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
signupPhoneNumber: '',
email: 'user@example.com',
}), false);
});
test('runtime signup phone sync preserves active manual input until it is saved', () => {
const api = new Function(`
let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111' };
let signupPhoneInputDirty = true;
let signupPhoneInputFocused = true;
const inputSignupPhone = { value: '+442222222222' };
const rowSignupPhone = { style: { display: 'none' } };
const inputPhoneVerificationEnabled = { checked: true };
const document = { activeElement: inputSignupPhone };
function getSelectedSignupMethod() { return 'phone'; }
${extractFunction('normalizeSignupMethod')}
${extractFunction('getRuntimeSignupPhoneValue')}
${extractFunction('getSignupPhoneInputValue')}
${extractFunction('shouldPreserveSignupPhoneInputValue')}
${extractFunction('syncSignupPhoneInputFromState')}
return {
inputSignupPhone,
rowSignupPhone,
syncSignupPhoneInputFromState,
getDirty: () => signupPhoneInputDirty,
setFocused: (value) => { signupPhoneInputFocused = Boolean(value); document.activeElement = value ? inputSignupPhone : null; },
};
`)();
api.syncSignupPhoneInputFromState({
signupMethod: 'phone',
phoneVerificationEnabled: true,
signupPhoneNumber: '+441111111111',
});
assert.equal(api.inputSignupPhone.value, '+442222222222');
assert.equal(api.rowSignupPhone.style.display, '');
assert.equal(api.getDirty(), true);
api.setFocused(false);
api.syncSignupPhoneInputFromState({
signupMethod: 'phone',
phoneVerificationEnabled: true,
signupPhoneNumber: '+441111111111',
});
assert.equal(api.inputSignupPhone.value, '+441111111111');
});
test('hero sms country helpers keep empty summary state and expose removable order handling', () => {
assert.match(
sidepanelSource,
/function removeHeroSmsCountryFromOrder\(id\)/
);
assert.match(
sidepanelSource,
/displayHeroSmsCountryFallbackOrder\.textContent = '';/
);
const api = new Function(`
const HERO_SMS_COUNTRY_SELECTION_MAX = 3;
const btnHeroSmsCountryMenu = { textContent: '' };
function isFiveSimProviderSelected() { return false; }
function normalizeFiveSimCountryFallbackList(value = []) { return Array.isArray(value) ? value : []; }
function normalizeHeroSmsCountryFallbackList(value = []) { return Array.isArray(value) ? value : []; }
${extractFunction('updateHeroSmsCountryMenuSummary')}
return { btnHeroSmsCountryMenu, updateHeroSmsCountryMenuSummary };
`)();
api.updateHeroSmsCountryMenuSummary([]);
assert.equal(api.btnHeroSmsCountryMenu.textContent, '\u672a\u9009\u62e9 (0/3)');
});
test('live phone country sources are not hard-filtered down to the reduced country whitelist', () => {
assert.doesNotMatch(
sidepanelSource,
/\.filter\(\(entry\) => entry\.id && FIVE_SIM_SUPPORTED_COUNTRY_ID_SET\.has\(String\(entry\.id\)\)\)/
);
assert.doesNotMatch(
sidepanelSource,
/\.filter\(\(item\) => HERO_SMS_SUPPORTED_COUNTRY_ID_SET\.has\(String\(Math\.floor\(Number\(item\?\.id\)\)\)\)/
);
assert.doesNotMatch(
sidepanelSource,
/\.filter\(\(entry\) => FIVE_SIM_SUPPORTED_COUNTRY_ID_SET\.has\(entry\.code \|\| entry\.id\)\)/
);
});
test('removeHeroSmsCountryFromOrder clears the selected country and triggers a silent save', async () => {
const api = new Function(`
let heroSmsCountrySelectionOrder = [52, 6];
const selectHeroSmsCountry = {
options: [
{ value: '52', selected: true },
{ value: '6', selected: true },
],
};
const selectHeroSmsCountryFallback = {
options: [
{ value: '52', selected: true },
{ value: '6', selected: true },
],
};
let dirtyValue = null;
let saveCount = 0;
let platformRefreshCount = 0;
function getSelectedPhoneSmsProvider() { return 'hero-sms'; }
function normalizePhoneSmsCountryId(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function syncHeroSmsFallbackSelectionOrderFromSelect() {
heroSmsCountrySelectionOrder = Array.from(selectHeroSmsCountry.options || [])
.filter((option) => option.selected)
.map((option) => Number(option.value));
return heroSmsCountrySelectionOrder.map((id) => ({ id, label: 'Country #' + id }));
}
function updateHeroSmsPlatformDisplay() { platformRefreshCount += 1; }
function markSettingsDirty(value) { dirtyValue = value; }
function saveSettings() { saveCount += 1; return Promise.resolve(); }
${extractFunction('removeHeroSmsCountryFromOrder')}
return {
removeHeroSmsCountryFromOrder,
selectHeroSmsCountry,
selectHeroSmsCountryFallback,
getHeroSmsCountrySelectionOrder: () => [...heroSmsCountrySelectionOrder],
getDirtyValue: () => dirtyValue,
getSaveCount: () => saveCount,
getPlatformRefreshCount: () => platformRefreshCount,
};
`)();
const nextOrder = api.removeHeroSmsCountryFromOrder(52);
await Promise.resolve();
assert.deepStrictEqual(nextOrder, [{ id: 6, label: 'Country #6' }]);
assert.deepStrictEqual(api.getHeroSmsCountrySelectionOrder(), [6]);
assert.equal(api.selectHeroSmsCountry.options[0].selected, false);
assert.equal(api.selectHeroSmsCountry.options[1].selected, true);
assert.equal(api.selectHeroSmsCountryFallback.options[0].selected, false);
assert.equal(api.selectHeroSmsCountryFallback.options[1].selected, true);
assert.equal(api.getDirtyValue(), true);
assert.equal(api.getSaveCount(), 1);
assert.equal(api.getPlatformRefreshCount(), 1);
});
test('updatePhoneVerificationSettingsUI toggles SMS rows from the sms switch and provider selection', () => {
const api = new Function(`
let phoneVerificationSectionExpanded = false;
let latestState = {};
const inputPhoneVerificationEnabled = { checked: false };
const rowPhoneVerificationEnabled = { style: { display: 'none' } };
const rowPhoneVerificationFold = { style: { display: 'none' } };
const rowSignupMethod = { style: { display: 'none' } };
const rowSignupPhone = { style: { display: 'none' } };
const rowPhoneSmsProvider = { style: { display: 'none' } };
const rowPhoneSmsProviderOrder = { style: { display: 'none' } };
const rowPhoneSmsProviderOrderActions = { style: { display: 'none' } };
@@ -159,6 +461,16 @@ const rowHeroSmsCountryFallback = { style: { display: 'none' } };
const rowHeroSmsAcquirePriority = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowFiveSimApiKey = { style: { display: 'none' } };
const rowFiveSimCountry = { style: { display: 'none' } };
const rowFiveSimCountryFallback = { style: { display: 'none' } };
const rowFiveSimOperator = { style: { display: 'none' } };
const rowFiveSimProduct = { style: { display: 'none' } };
const rowNexSmsApiKey = { style: { display: 'none' } };
const rowNexSmsCountry = { style: { display: 'none' } };
const rowNexSmsCountryFallback = { style: { display: 'none' } };
const rowNexSmsServiceCode = { style: { display: 'none' } };
const rowHeroSmsRuntimePair = { style: { display: 'none' } };
const rowHeroSmsCurrentNumber = { style: { display: 'none' } };
const rowHeroSmsCurrentCountdown = { style: { display: 'none' } };
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
@@ -170,22 +482,28 @@ const rowPhoneCodeWaitSeconds = { style: { display: 'none' } };
const rowPhoneCodeTimeoutWindows = { style: { display: 'none' } };
const rowPhoneCodePollIntervalSeconds = { style: { display: 'none' } };
const rowPhoneCodePollMaxRounds = { style: { display: 'none' } };
const rowFiveSimApiKey = { style: { display: 'none' } };
const rowFiveSimCountry = { style: { display: 'none' } };
const rowFiveSimCountryFallback = { style: { display: 'none' } };
const rowFiveSimOperator = { style: { display: 'none' } };
const rowFiveSimProduct = { style: { display: 'none' } };
const rowNexSmsApiKey = { style: { display: 'none' } };
const rowNexSmsCountry = { style: { display: 'none' } };
const rowNexSmsCountryFallback = { style: { display: 'none' } };
const rowNexSmsServiceCode = { style: { display: 'none' } };
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
function getSelectedPhoneSmsProvider() { return selectPhoneSmsProvider.value; }
function isFiveSimProviderSelected() { return getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM; }
function updateHeroSmsPlatformDisplay() {}
function updateSignupMethodUI() {
rowSignupMethod.style.display = inputPhoneVerificationEnabled.checked ? '' : 'none';
}
function syncSignupPhoneInputFromState() {
rowSignupPhone.style.display = inputPhoneVerificationEnabled.checked && latestState.signupPhoneNumber ? '' : 'none';
}
${extractFunction('updatePhoneVerificationSettingsUI')}
return {
setExpanded(value) { phoneVerificationSectionExpanded = Boolean(value); },
setLatestState: (state) => { latestState = state || {}; },
rowPhoneVerificationEnabled,
rowPhoneVerificationFold,
rowSignupMethod,
rowSignupPhone,
rowPhoneSmsProvider,
rowPhoneSmsProviderOrder,
rowPhoneSmsProviderOrderActions,
@@ -198,6 +516,16 @@ return {
rowHeroSmsAcquirePriority,
rowHeroSmsApiKey,
rowHeroSmsMaxPrice,
rowFiveSimApiKey,
rowFiveSimCountry,
rowFiveSimCountryFallback,
rowFiveSimOperator,
rowFiveSimProduct,
rowNexSmsApiKey,
rowNexSmsCountry,
rowNexSmsCountryFallback,
rowNexSmsServiceCode,
rowHeroSmsRuntimePair,
rowHeroSmsCurrentNumber,
rowHeroSmsCurrentCountdown,
rowHeroSmsPriceTiers,
@@ -209,15 +537,7 @@ return {
rowPhoneCodeTimeoutWindows,
rowPhoneCodePollIntervalSeconds,
rowPhoneCodePollMaxRounds,
rowFiveSimApiKey,
rowFiveSimCountry,
rowFiveSimCountryFallback,
rowFiveSimOperator,
rowFiveSimProduct,
rowNexSmsApiKey,
rowNexSmsCountry,
rowNexSmsCountryFallback,
rowNexSmsServiceCode,
setSelectedPhoneSmsProvider(value) { selectPhoneSmsProvider.value = value; },
updatePhoneVerificationSettingsUI,
};
`)();
@@ -225,17 +545,21 @@ return {
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationEnabled.style.display, '');
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
assert.equal(api.rowSignupMethod.style.display, 'none');
assert.equal(api.rowSignupPhone.style.display, 'none');
assert.equal(api.rowPhoneSmsProvider.style.display, 'none');
assert.equal(api.rowPhoneSmsProviderOrder.style.display, 'none');
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, 'none');
assert.equal(api.btnTogglePhoneVerificationSection.disabled, true);
assert.equal(api.btnTogglePhoneVerificationSection.textContent, '展开设置');
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
assert.equal(api.rowHeroSmsPlatform.style.display, '');
assert.equal(api.rowHeroSmsRuntimePair.style.display, 'none');
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsCountryFallback.style.display, 'none');
assert.equal(api.rowHeroSmsAcquirePriority.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
assert.equal(api.rowFiveSimOperator.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, 'none');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
@@ -258,8 +582,22 @@ return {
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.inputPhoneVerificationEnabled.checked = true;
api.setLatestState({ signupPhoneNumber: '66959916439' });
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationFold.style.display, 'none');
assert.equal(api.rowSignupMethod.style.display, '');
assert.equal(api.rowSignupPhone.style.display, '');
assert.equal(api.rowPhoneSmsProvider.style.display, 'none');
assert.equal(api.rowHeroSmsRuntimePair.style.display, '');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, '');
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, '');
assert.equal(api.rowHeroSmsCurrentCode.style.display, '');
assert.equal(api.rowHeroSmsPreferredActivation.style.display, '');
api.setExpanded(true);
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowPhoneVerificationFold.style.display, '');
assert.equal(api.rowSignupMethod.style.display, '');
assert.equal(api.rowPhoneSmsProvider.style.display, '');
assert.equal(api.rowPhoneSmsProviderOrder.style.display, '');
assert.equal(api.rowPhoneSmsProviderOrderActions.style.display, '');
@@ -271,6 +609,7 @@ return {
assert.equal(api.rowHeroSmsAcquirePriority.style.display, '');
assert.equal(api.rowHeroSmsApiKey.style.display, '');
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
assert.equal(api.rowFiveSimOperator.style.display, 'none');
assert.equal(api.rowHeroSmsCurrentNumber.style.display, '');
assert.equal(api.rowHeroSmsCurrentCountdown.style.display, '');
assert.equal(api.rowHeroSmsPriceTiers.style.display, 'none');
@@ -282,37 +621,17 @@ return {
assert.equal(api.rowPhoneCodeTimeoutWindows.style.display, '');
assert.equal(api.rowPhoneCodePollIntervalSeconds.style.display, '');
assert.equal(api.rowPhoneCodePollMaxRounds.style.display, '');
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
assert.equal(api.rowFiveSimCountry.style.display, 'none');
assert.equal(api.rowFiveSimCountryFallback.style.display, 'none');
assert.equal(api.rowFiveSimOperator.style.display, 'none');
assert.equal(api.rowFiveSimProduct.style.display, 'none');
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
assert.equal(api.rowNexSmsCountry.style.display, 'none');
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.selectPhoneSmsProvider.value = '5sim';
api.setLatestState({ phoneSmsProviderOrder: ['5sim'] });
api.setSelectedPhoneSmsProvider('5sim');
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
assert.equal(api.rowFiveSimApiKey.style.display, '');
assert.equal(api.rowFiveSimCountry.style.display, '');
assert.equal(api.rowFiveSimCountryFallback.style.display, '');
assert.equal(api.rowFiveSimOperator.style.display, '');
assert.equal(api.rowFiveSimProduct.style.display, '');
assert.equal(api.rowNexSmsApiKey.style.display, 'none');
assert.equal(api.rowNexSmsCountry.style.display, 'none');
assert.equal(api.rowNexSmsCountryFallback.style.display, 'none');
assert.equal(api.rowNexSmsServiceCode.style.display, 'none');
api.selectPhoneSmsProvider.value = 'nexsms';
api.setLatestState({ phoneSmsProviderOrder: ['nexsms'] });
api.setSelectedPhoneSmsProvider('nexsms');
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowFiveSimApiKey.style.display, 'none');
assert.equal(api.rowFiveSimCountry.style.display, 'none');
assert.equal(api.rowNexSmsApiKey.style.display, '');
assert.equal(api.rowNexSmsCountry.style.display, '');
assert.equal(api.rowNexSmsCountryFallback.style.display, '');
@@ -321,6 +640,7 @@ return {
test('collectSettingsPayload keeps local helper sync enabled while persisting sms toggle state', () => {
const api = new Function('normalizeIcloudTargetMailboxType', 'normalizeIcloudForwardMailProvider', `
const window = {};
let latestState = {
contributionMode: false,
mail2925UseAccountPool: false,
@@ -372,6 +692,8 @@ const inputHeroSmsApiKey = { value: 'demo-key' };
const inputFiveSimApiKey = { value: 'five-sim-key' };
const inputFiveSimOperator = { value: 'any' };
const inputFiveSimProduct = { value: 'openai' };
const inputNexSmsApiKey = { value: 'nex-key' };
const inputNexSmsServiceCode = { value: 'ot' };
const inputHeroSmsReuseEnabled = { checked: true };
const selectHeroSmsAcquirePriority = { value: 'price' };
function getSelectedPhonePreferredActivation() {
@@ -386,6 +708,7 @@ function getSelectedPhonePreferredActivation() {
};
}
const inputHeroSmsMaxPrice = { value: '0.12' };
const inputHeroSmsPreferredPrice = { value: '0.0512' };
const inputPhoneReplacementLimit = { value: '5' };
const inputPhoneCodeWaitSeconds = { value: '75' };
const inputPhoneCodeTimeoutWindows = { value: '3' };
@@ -414,6 +737,18 @@ const PHONE_REPLACEMENT_LIMIT_MIN = 1;
const PHONE_REPLACEMENT_LIMIT_MAX = 20;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const DEFAULT_NEX_SMS_COUNTRY_ORDER = [1];
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const selectHeroSmsCountry = {
value: '52',
selectedIndex: 0,
@@ -424,6 +759,7 @@ function normalizeCloudflareDomainValue(value) { return String(value || '').trim
function getCloudflareTempEmailDomainsFromState() { return { domains: [], activeDomain: '' }; }
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
function getSelectedPlusPaymentMethod() { return 'paypal'; }
function getSelectedMail2925Mode() { return 'provide'; }
function getSelectedHotmailServiceMode() { return 'local'; }
function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; }
@@ -436,6 +772,22 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
${extractFunction('normalizePhoneSmsProvider')}
${extractFunction('normalizePhoneSmsProviderValue')}
${extractFunction('normalizeFiveSimCountryCode')}
${extractFunction('normalizeFiveSimCountryOrderValue')}
${extractFunction('normalizeFiveSimProductValue')}
${extractFunction('normalizeNexSmsCountryIdValue')}
${extractFunction('normalizeNexSmsCountryOrderValue')}
${extractFunction('normalizeNexSmsServiceCodeValue')}
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
function getSelectedPhoneSmsProviderOrder() { return ['nexsms', '5sim']; }
${extractFunction('normalizeFiveSimCountryId')}
${extractFunction('normalizeFiveSimCountryLabel')}
${extractFunction('normalizeFiveSimOperator')}
${extractFunction('normalizeFiveSimMaxPriceValue')}
${extractFunction('normalizeFiveSimCountryFallbackList')}
${extractFunction('normalizePhoneSmsMaxPriceValue')}
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('normalizePhoneVerificationReplacementLimit')}
${extractFunction('normalizePhoneCodeWaitSecondsValue')}
@@ -450,6 +802,15 @@ ${extractFunction('getSelectedHeroSmsCountryOption')}
function syncHeroSmsFallbackSelectionOrderFromSelect() {
return [{ id: 52, label: 'Thailand' }, { id: 16, label: 'United Kingdom' }];
}
function getSelectedSignupMethod() { return 'phone'; }
${extractFunction('normalizePanelMode')}
${extractFunction('getSelectedPanelMode')}
function getSelectedFiveSimCountries() {
return [{ id: 'thailand', code: 'thailand', label: 'Thailand' }, { id: 'vietnam', code: 'vietnam', label: 'Vietnam' }];
}
function getSelectedNexSmsCountries() {
return [{ id: 1, label: 'Country #1' }];
}
${extractFunction('collectSettingsPayload')}
return { collectSettingsPayload };
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
@@ -457,17 +818,23 @@ return { collectSettingsPayload };
const payload = api.collectSettingsPayload();
assert.equal(payload.phoneVerificationEnabled, true);
assert.equal(payload.signupMethod, 'phone');
assert.equal(payload.phoneSmsProvider, 'hero-sms');
assert.deepStrictEqual(payload.phoneSmsProviderOrder, ['nexsms', '5sim']);
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'england']);
assert.deepStrictEqual(payload.fiveSimCountryOrder, ['thailand', 'vietnam']);
assert.equal(payload.fiveSimOperator, 'any');
assert.equal(payload.fiveSimProduct, 'openai');
assert.equal(payload.nexSmsApiKey, 'nex-key');
assert.deepStrictEqual(payload.nexSmsCountryOrder, [1]);
assert.equal(payload.nexSmsServiceCode, 'ot');
assert.equal(payload.heroSmsReuseEnabled, true);
assert.equal(payload.heroSmsAcquirePriority, 'price');
assert.equal(payload.heroSmsMaxPrice, '0.12');
assert.equal(payload.heroSmsPreferredPrice, '0.0512');
assert.deepStrictEqual(payload.phonePreferredActivation, {
provider: 'hero-sms',
activationId: 'demo-activation',
@@ -485,6 +852,239 @@ return { collectSettingsPayload };
assert.equal(payload.heroSmsCountryId, 52);
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
assert.deepStrictEqual(payload.heroSmsCountryFallback, [{ id: 16, label: 'United Kingdom' }]);
assert.equal(payload.fiveSimApiKey, 'five-sim-key');
assert.equal(payload.fiveSimCountryId, 'vietnam');
});
test('switchPhoneSmsProvider saves API keys independently when the select value has already changed', async () => {
const api = new Function(`
let latestState = {
phoneSmsProvider: 'hero-sms',
heroSmsApiKey: 'hero-old',
fiveSimApiKey: 'five-old',
heroSmsMaxPrice: '0.11',
fiveSimMaxPrice: '12',
heroSmsCountryId: 52,
heroSmsCountryLabel: 'Thailand',
heroSmsCountryFallback: [],
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimCountryFallback: [],
fiveSimOperator: 'any',
};
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
const DEFAULT_HERO_SMS_COUNTRY_LABEL = 'Thailand';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const selectPhoneSmsProvider = { value: 'hero-sms', dataset: { activeProvider: 'hero-sms' } };
const inputHeroSmsApiKey = { value: 'hero-live' };
const inputHeroSmsMaxPrice = { value: '0.22' };
const inputFiveSimOperator = { value: 'any' };
const displayHeroSmsPriceTiers = { textContent: '' };
const displayPhoneSmsBalance = { textContent: '' };
const rowHeroSmsPriceTiers = { style: { display: '' } };
let heroSmsCountrySelectionOrder = [];
let savedPayload = null;
${extractFunction('normalizePhoneSmsProvider')}
${extractFunction('setPhoneSmsProviderSelectValue')}
${extractFunction('getLastAppliedPhoneSmsProvider')}
function getSelectedPhoneSmsProvider() { return normalizePhoneSmsProvider(selectPhoneSmsProvider?.value || latestState?.phoneSmsProvider); }
${extractFunction('normalizeFiveSimCountryId')}
${extractFunction('normalizeFiveSimCountryLabel')}
${extractFunction('normalizeFiveSimOperator')}
${extractFunction('normalizeFiveSimMaxPriceValue')}
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('normalizePhoneSmsMaxPriceValue')}
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
${extractFunction('normalizeHeroSmsCountryFallbackList')}
${extractFunction('normalizeFiveSimCountryFallbackList')}
function getSelectedHeroSmsCountryOption() {
return getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM
? { id: latestState.fiveSimCountryId || DEFAULT_FIVE_SIM_COUNTRY_ID, label: latestState.fiveSimCountryLabel || DEFAULT_FIVE_SIM_COUNTRY_LABEL }
: { id: latestState.heroSmsCountryId || DEFAULT_HERO_SMS_COUNTRY_ID, label: latestState.heroSmsCountryLabel || DEFAULT_HERO_SMS_COUNTRY_LABEL };
}
function syncHeroSmsFallbackSelectionOrderFromSelect() {
return getSelectedPhoneSmsProvider() === PHONE_SMS_PROVIDER_FIVE_SIM
? [{ id: 'vietnam', label: '越南 (Vietnam)' }]
: [{ id: 52, label: 'Thailand' }];
}
function syncLatestState(patch) { latestState = { ...latestState, ...patch }; }
function loadHeroSmsCountries() { return Promise.resolve(); }
function applyHeroSmsFallbackSelection() {}
function updatePhoneVerificationSettingsUI() {}
function markSettingsDirty() {}
function saveSettings() { savedPayload = { ...latestState }; return Promise.resolve(); }
${extractFunction('switchPhoneSmsProvider')}
return {
selectPhoneSmsProvider,
inputHeroSmsApiKey,
get latestState() { return latestState; },
get savedPayload() { return savedPayload; },
switchPhoneSmsProvider,
};
`)();
// Browser change events update <select>.value before the listener runs.
api.selectPhoneSmsProvider.value = '5sim';
await api.switchPhoneSmsProvider(api.selectPhoneSmsProvider.value);
assert.equal(api.latestState.phoneSmsProvider, '5sim');
assert.equal(api.latestState.heroSmsApiKey, 'hero-live');
assert.equal(api.latestState.fiveSimApiKey, 'five-old');
assert.equal(api.inputHeroSmsApiKey.value, 'five-old');
assert.equal(api.selectPhoneSmsProvider.dataset.activeProvider, '5sim');
api.inputHeroSmsApiKey.value = 'five-live';
api.selectPhoneSmsProvider.value = 'hero-sms';
await api.switchPhoneSmsProvider(api.selectPhoneSmsProvider.value);
assert.equal(api.latestState.phoneSmsProvider, 'hero-sms');
assert.equal(api.latestState.heroSmsApiKey, 'hero-live');
assert.equal(api.latestState.fiveSimApiKey, 'five-live');
assert.equal(api.inputHeroSmsApiKey.value, 'hero-live');
assert.equal(api.selectPhoneSmsProvider.dataset.activeProvider, 'hero-sms');
assert.equal(api.savedPayload.heroSmsApiKey, 'hero-live');
assert.equal(api.savedPayload.fiveSimApiKey, 'five-live');
});
test('formatPhoneSmsPriceEntriesSummary treats HeroSMS physicalCount=0 as out of stock even when count is positive', () => {
const api = new Function(`
${extractFunction('normalizeHeroSmsPriceForPreview')}
${extractFunction('collectHeroSmsPriceEntriesForPreview')}
${extractFunction('formatPhoneSmsPriceEntriesSummary')}
return { formatPhoneSmsPriceEntriesSummary };
`)();
const summary = api.formatPhoneSmsPriceEntriesSummary({
52: {
dr: {
cost: 0.05,
count: 3,
physicalCount: 0,
},
},
});
assert.deepStrictEqual(summary.inStockPrices, []);
assert.deepStrictEqual(summary.allPrices, [0.05]);
assert.equal(summary.entries[0].inStock, false);
assert.equal(summary.entries[0].stockCount, 0);
});
test('previewHeroSmsPriceTiers prefers 5sim products price for buy-compatible any operator', async () => {
const api = new Function(`
let latestState = { phoneSmsProvider: '5sim', fiveSimOperator: 'any' };
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_FIVE_SIM_COUNTRY_ID = 'vietnam';
const DEFAULT_FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_FIVE_SIM_OPERATOR = 'any';
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const inputHeroSmsMaxPrice = { value: '' };
const inputHeroSmsApiKey = { value: '' };
const inputFiveSimOperator = { value: 'any' };
const inputFiveSimProduct = { value: 'openai' };
const displayHeroSmsPriceTiers = { textContent: '' };
const rowHeroSmsPriceTiers = { style: { display: 'none' } };
const fetchCalls = [];
${extractFunction('normalizePhoneSmsProvider')}
${extractFunction('normalizePhoneSmsProviderValue')}
${extractFunction('normalizePhoneSmsProviderOrderValue')}
const phoneSmsProviderOrderSelection = [];
function getSelectedPhoneSmsProvider() { return '5sim'; }
function getSelectedPhoneSmsProviderOrder() { return ['5sim']; }
${extractFunction('normalizeFiveSimCountryId')}
${extractFunction('normalizeFiveSimCountryLabel')}
${extractFunction('normalizeFiveSimCountryCode')}
${extractFunction('normalizeFiveSimProductValue')}
${extractFunction('normalizeFiveSimOperator')}
${extractFunction('normalizePhoneSmsMaxPriceValue')}
${extractFunction('normalizeFiveSimMaxPriceValue')}
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('normalizeHeroSmsPriceForPreview')}
${extractFunction('formatHeroSmsPriceForPreview')}
${extractFunction('isHeroSmsPreviewEmptyPayload')}
${extractFunction('collectHeroSmsPriceEntriesForPreview')}
${extractFunction('formatPhoneSmsPriceEntriesSummary')}
${extractFunction('describeHeroSmsPreviewPayload')}
${extractFunction('summarizeHeroSmsPreviewError')}
${extractFunction('formatPriceTiersForPreview')}
${extractFunction('formatPriceTiersWithZeroStockForPreview')}
function normalizeHeroSmsFetchErrorMessage(error) { return error?.message || String(error); }
function getFiveSimCountryLabelByCode() { return '越南 (Vietnam)'; }
function getSelectedFiveSimCountries() {
return [{ id: 'vietnam', code: 'vietnam', label: '越南 (Vietnam)' }];
}
function syncHeroSmsFallbackSelectionOrderFromSelect() {
return [{ id: 'vietnam', label: '越南 (Vietnam)' }];
}
function getSelectedHeroSmsCountryOption() {
return { id: 'vietnam', label: '越南 (Vietnam)' };
}
function normalizePhoneSmsCountryId(value) { return normalizeFiveSimCountryId(value); }
function normalizePhoneSmsCountryLabel(value) { return normalizeFiveSimCountryLabel(value); }
function getHeroSmsCountryLabelById() { return '越南 (Vietnam)'; }
async function fetch(url, options = {}) {
const parsed = new URL(url);
fetchCalls.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return {
ok: true,
status: 200,
json: async () => ({ openai: { Category: 'activation', Qty: 4609, Price: 0.08 } }),
};
}
if (parsed.pathname === '/v1/guest/prices') {
return {
ok: true,
status: 200,
json: async () => ({
vietnam: {
openai: {
virtual21: { cost: 0.0769, count: 0 },
virtual47: { cost: 0.1282, count: 4608 },
},
},
}),
};
}
throw new Error('unexpected ' + parsed.pathname);
}
${extractFunction('buildFiveSimPricePreviewLines')}
${extractFunction('previewHeroSmsPriceTiers')}
return {
displayHeroSmsPriceTiers,
rowHeroSmsPriceTiers,
fetchCalls,
previewHeroSmsPriceTiers,
};
`)();
await api.previewHeroSmsPriceTiers();
assert.equal(
api.displayHeroSmsPriceTiers.textContent,
'5sim:\n越南 (Vietnam): 最低 0.1282;档位:0.0769(x0), 0.1282(x4608)'
);
assert.equal(api.rowHeroSmsPriceTiers.style.display, '');
assert.deepStrictEqual(
api.fetchCalls.map((entry) => entry.url.pathname),
['/v1/guest/prices']
);
});
test('hero sms max price input does not auto-save partial typing states', () => {
@@ -1,312 +0,0 @@
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',
'enterContributionModeFromPlusPrompt',
'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',
'enterContributionModeFromPlusPrompt',
'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';
}
const chrome = {
runtime: {
async sendMessage(message) {
events.push({ type: 'runtime', message });
return { state: { contributionMode: true } };
},
},
};
function applySettingsState(state) {
events.push({ type: 'apply', state });
}
function renderContributionMode() {
events.push({ type: 'render' });
}
${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', 'runtime', 'apply', 'render', 'toast']);
assert.equal(api.getEvents()[1].url, 'https://apikey.qzz.io');
assert.deepEqual(api.getEvents()[2].message, {
type: 'SET_CONTRIBUTION_MODE',
source: 'sidepanel',
payload: { enabled: true },
});
});
+210 -1
View File
@@ -32,8 +32,38 @@ function extractFunction(name) {
return sidepanelSource.slice(start, end);
}
function extractLastFunction(name) {
const asyncStart = sidepanelSource.lastIndexOf(`async function ${name}`);
const normalStart = sidepanelSource.lastIndexOf(`function ${name}`);
const asyncInnerFunctionStart = asyncStart >= 0 ? asyncStart + 'async '.length : -1;
const start = asyncStart >= 0 && normalStart === asyncInnerFunctionStart
? asyncStart
: (asyncStart > normalStart ? asyncStart : normalStart);
if (start === -1) {
throw new Error(`Function ${name} not found`);
}
const signatureEnd = sidepanelSource.indexOf(')', start);
const bodyStart = sidepanelSource.indexOf('{', signatureEnd);
let depth = 0;
let end = bodyStart;
for (; end < sidepanelSource.length; end += 1) {
const char = sidepanelSource[end];
if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return sidepanelSource.slice(start, end);
}
test('sidepanel step definitions keep the selected Plus payment method', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('rebuildStepDefinitionState'),
@@ -52,6 +82,8 @@ const window = {
};
let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
let currentSignupMethod = 'email';
const DEFAULT_SIGNUP_METHOD = 'email';
let stepDefinitions = [];
let STEP_IDS = [];
let STEP_DEFAULT_STATUSES = {};
@@ -74,11 +106,28 @@ return {
assert.deepEqual(api.getStepIds(), [7]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { plusModeEnabled: true, plusPaymentMethod: 'gopay' },
options: { plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' },
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
});
test('sidepanel normalizeSignupMethod stays independent from signup constants during bootstrap', () => {
const source = extractFunction('normalizeSignupMethod');
assert.doesNotMatch(source, /SIGNUP_METHOD_(PHONE|EMAIL)/);
});
test('sidepanel signup method UI syncs shared step definitions with the selected signup method', () => {
const source = extractFunction('updateSignupMethodUI');
assert.match(source, /syncStepDefinitionsForMode\(/);
assert.match(source, /signupMethod:\s*selectedMethod/);
});
test('sidepanel applies restored signup method when rebuilding shared step definitions on load', () => {
const source = extractFunction('applySettingsState');
assert.match(source, /syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{/);
assert.match(source, /signupMethod:\s*state\?\.signupMethod/);
});
test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
@@ -106,6 +155,107 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
assert.equal(api.rowPayPalAccount.style.display, '');
});
test('sidepanel step definitions keep GPC helper mode distinct', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getStepDefinitionsForMode'),
extractFunction('rebuildStepDefinitionState'),
extractFunction('syncStepDefinitionsForMode'),
].join('\n');
const api = new Function(`
const calls = [];
const window = {
MultiPageStepDefinitions: {
getSteps(options) {
calls.push({ type: 'getSteps', options });
return [{ id: options.plusPaymentMethod === 'gpc-helper' ? 13 : 6, order: 1 }];
},
},
};
let currentPlusModeEnabled = false;
let currentPlusPaymentMethod = 'paypal';
let currentSignupMethod = 'email';
const DEFAULT_SIGNUP_METHOD = 'email';
let stepDefinitions = [];
let STEP_IDS = [];
let STEP_DEFAULT_STATUSES = {};
let SKIPPABLE_STEPS = new Set();
function renderStepsList() {
calls.push({ type: 'render', stepIds: [...STEP_IDS] });
}
${bundle}
return {
calls,
syncStepDefinitionsForMode,
getCurrentPlusPaymentMethod: () => currentPlusPaymentMethod,
getStepIds: () => [...STEP_IDS],
};
`)();
api.syncStepDefinitionsForMode(true, 'gpc-helper', { render: true });
assert.equal(api.getCurrentPlusPaymentMethod(), 'gpc-helper');
assert.deepEqual(api.getStepIds(), [13]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' },
});
});
test('sidepanel Plus UI shows GPC fields and purchase button only for GPC without API input', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('updatePlusModeUI'),
].join('\n');
const api = new Function(`
let latestState = { plusPaymentMethod: 'gpc-helper' };
let currentPlusPaymentMethod = 'paypal';
const inputPlusModeEnabled = { checked: true };
const selectPlusPaymentMethod = { value: 'gpc-helper', style: { display: 'none' } };
const plusPaymentMethodCaption = { textContent: '' };
const btnGpcCardKeyPurchase = { style: { display: 'none' } };
const rowPayPalAccount = { style: { display: '' } };
const rowPlusPaymentMethod = { style: { display: 'none' } };
const rowGpcHelperApi = { style: { display: 'none' } };
const rowGpcHelperCardKey = { style: { display: 'none' } };
const rowGpcHelperCountryCode = { style: { display: 'none' } };
const rowGpcHelperPhone = { style: { display: 'none' } };
const rowGpcHelperPin = { style: { display: 'none' } };
const rowGoPayCountryCode = { style: { display: 'none' } };
const rowGoPayPhone = { style: { display: 'none' } };
const rowGoPayOtp = { style: { display: 'none' } };
const rowGoPayPin = { style: { display: 'none' } };
${bundle}
return {
updatePlusModeUI,
selectPlusPaymentMethod,
btnGpcCardKeyPurchase,
rowPayPalAccount,
plusPaymentMethodCaption,
rows: { rowGpcHelperApi, rowGpcHelperCardKey, rowGpcHelperCountryCode, rowGpcHelperPhone, rowGpcHelperPin },
};
`)();
api.updatePlusModeUI();
assert.equal(api.rowPayPalAccount.style.display, 'none');
assert.equal(api.btnGpcCardKeyPurchase.style.display, '');
assert.equal(api.rows.rowGpcHelperApi.style.display, 'none');
assert.equal(api.rows.rowGpcHelperCardKey.style.display, '');
assert.equal(api.rows.rowGpcHelperPhone.style.display, '');
assert.match(api.plusPaymentMethodCaption.textContent, /GPC/);
api.selectPlusPaymentMethod.value = 'gopay';
api.updatePlusModeUI();
assert.equal(api.btnGpcCardKeyPurchase.style.display, 'none');
assert.equal(api.rows.rowGpcHelperApi.style.display, 'none');
assert.equal(api.rowPayPalAccount.style.display, 'none');
});
test('sidepanel resolves pending GoPay manual confirmation from DATA_UPDATED state', async () => {
const bundle = [
extractFunction('openPlusManualConfirmationDialog'),
@@ -166,3 +316,62 @@ return { events, syncPlusManualConfirmationDialog };
assert.match(api.events[2].message, /GoPay/);
assert.equal(api.events[2].tone, 'info');
});
test('sidepanel resolves pending GPC OTP with typed code', async () => {
const bundle = [
extractLastFunction('openPlusManualConfirmationDialog'),
extractLastFunction('syncPlusManualConfirmationDialog'),
].join('\n');
const api = new Function(`
const events = [];
let latestState = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'otp-request-1',
plusManualConfirmationStep: 7,
plusManualConfirmationMethod: 'gopay-otp',
plusManualConfirmationTitle: 'GPC OTP 验证',
plusManualConfirmationMessage: '',
};
let activePlusManualConfirmationRequestId = '';
let plusManualConfirmationDialogInFlight = false;
const sharedFormDialog = {
async open(options) {
events.push({ type: 'form', options });
return { otp: ' 12-34 56 ' };
},
};
function openActionModal(options) {
events.push({ type: 'modal', options });
return Promise.resolve('confirm');
}
function showToast(message, tone) {
events.push({ type: 'toast', message, tone });
}
const chrome = {
runtime: {
async sendMessage(message) {
events.push({ type: 'send', message });
latestState = { ...latestState, plusManualConfirmationPending: false };
return { ok: true };
},
},
};
${bundle}
return { events, syncPlusManualConfirmationDialog };
`)();
await api.syncPlusManualConfirmationDialog();
assert.equal(api.events[0].type, 'form');
assert.equal(api.events[0].options.message, '请在WhatsApp里面获取验证码(耐心等待三十秒左右)');
assert.equal(api.events[0].options.confirmLabel, '提交 OTP');
const sendEvent = api.events.find((event) => event.type === 'send');
assert.deepEqual(sendEvent.message.payload, {
step: 7,
requestId: 'otp-request-1',
confirmed: true,
otp: '123456',
});
assert.equal(api.events.some((event) => event.type === 'modal'), false);
});
+11
View File
@@ -51,6 +51,17 @@ function extractFunction(name) {
return source.slice(start, end);
}
test('signup password detector accepts create-account and phone login password paths', () => {
const run = (pathname) => new Function('location', `
${extractFunction('isSignupPasswordPage')}
return isSignupPasswordPage();
`)({ pathname });
assert.equal(run('/create-account/password'), true);
assert.equal(run('/log-in/password'), true);
assert.equal(run('/log-in'), false);
});
test('signup entry diagnostics summarizes current page inputs and visible actions', () => {
const api = new Function(`
const SIGNUP_ENTRY_TRIGGER_PATTERN = /免费注册|立即注册|注册|sign\\s*up|register|create\\s*account|create\\s+account/i;
File diff suppressed because it is too large Load Diff
@@ -304,3 +304,68 @@ return {
state: 'verification',
});
});
test('logged-out chatgpt homepage with signup and login actions is not treated as logged-in home', () => {
const api = new Function(`
const location = {
href: 'https://chatgpt.com/',
};
const signupButton = {
textContent: '免费注册',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
};
const loginButton = {
textContent: '登录',
disabled: false,
getAttribute(name) {
if (name === 'type') return 'button';
return '';
},
};
const document = {
querySelectorAll(selector) {
if (selector === 'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [signupButton, loginButton];
}
return [];
},
};
function findSignupEntryTrigger() {
return signupButton;
}
function getActionText(el) {
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
.filter(Boolean)
.join(' ')
.replace(/\\s+/g, ' ')
.trim();
}
function isVisibleElement() {
return true;
}
function isActionEnabled(el) {
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
}
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
return {
run() {
return isLikelyLoggedInChatgptHomeUrl();
},
};
`)();
assert.equal(api.run(), false);
});
+45 -3
View File
@@ -8,8 +8,11 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope);
const steps = api.getSteps();
const phoneSteps = api.getSteps({ signupMethod: 'phone' });
const plusSteps = api.getSteps({ plusModeEnabled: true });
const plusPhoneSteps = api.getSteps({ plusModeEnabled: true, signupMethod: 'phone' });
const goPaySteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gopay' });
const gpcSteps = api.getSteps({ plusModeEnabled: true, plusPaymentMethod: 'gpc-helper' });
assert.equal(Array.isArray(steps), true);
assert.equal(steps.length, 10);
@@ -34,6 +37,8 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
);
assert.equal(steps[0].title, '打开 ChatGPT 官网');
assert.equal(steps[5].title, '清理登录 Cookies');
assert.equal(phoneSteps[1].title, '注册并输入手机号');
assert.equal(phoneSteps[3].title, '获取手机验证码');
assert.deepStrictEqual(
plusSteps.map((step) => step.key),
@@ -55,6 +60,12 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
);
assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false);
assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), true);
assert.equal(plusSteps.find((step) => step.key === 'paypal-approve')?.title, 'PayPal 登录与授权');
assert.equal(plusPhoneSteps[1].title, '注册并输入手机号');
assert.equal(plusPhoneSteps[3].title, '获取手机验证码');
assert.equal(goPaySteps.some((step) => step.key === 'paypal-approve'), false);
assert.equal(api.getStepById(8, { plusModeEnabled: true, plusPaymentMethod: 'gopay' }), null);
assert.equal(api.getPlusPaymentStepTitle({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }), '');
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13);
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
@@ -80,6 +91,27 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }), 13);
assert.equal(goPaySteps[5].title, '打开 GoPay 订阅页');
assert.equal(goPaySteps[6].title, '等待 GoPay 订阅确认');
assert.deepStrictEqual(
gpcSteps.map((step) => step.key),
[
'open-chatgpt',
'submit-signup-email',
'fill-password',
'fetch-signup-code',
'fill-profile',
'plus-checkout-create',
'plus-checkout-billing',
'oauth-login',
'fetch-login-code',
'confirm-oauth',
'platform-verify',
]
);
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true, plusPaymentMethod: 'gpc-helper' }), [1, 2, 3, 4, 5, 6, 7, 10, 11, 12, 13]);
assert.equal(api.getLastStepId({ plusModeEnabled: true, plusPaymentMethod: 'gpc-helper' }), 13);
assert.equal(gpcSteps[5].title, '创建 GPC 订单');
assert.equal(gpcSteps[6].title, 'GPC OTP/PIN 验证');
});
test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => {
@@ -92,13 +124,23 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
assert.ok(definitionsIndex < sidepanelIndex);
});
test('sidepanel html exposes Plus mode payment controls and PayPal settings', () => {
test('sidepanel html exposes Plus mode, PayPal, and GoPay settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="select-plus-payment-method"/);
assert.match(html, /<option value="paypal">PayPal 支付<\/option>/);
assert.match(html, /<option value="gopay">GoPay 支付<\/option>/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="input-gopay-phone"/);
assert.match(html, /id="input-gopay-otp"/);
assert.match(html, /id="input-gopay-pin"/);
assert.match(html, /<option value="gpc-helper">GPC<\/option>/);
assert.match(html, /id="btn-gpc-card-key-purchase"/);
assert.match(html, />购买卡密</);
assert.doesNotMatch(html, /GPC API/);
assert.doesNotMatch(html, /id="input-gpc-helper-api"/);
assert.match(html, /id="input-gpc-helper-card-key"/);
assert.match(html, /id="btn-gpc-helper-balance"/);
assert.match(html, /id="input-gpc-helper-phone"/);
assert.match(html, /id="input-gpc-helper-pin"/);
assert.match(html, /id="shared-form-modal"/);
});
+20
View File
@@ -80,6 +80,26 @@ function inspectSignupEntryState() {
return snapshot;
}
function isPhoneVerificationPageReady() {
return false;
}
function getPhoneVerificationDisplayedPhone() {
return '';
}
function getVerificationCodeTarget() {
return null;
}
function getStep4PostVerificationState() {
return null;
}
function isVerificationPageStillVisible() {
return false;
}
async function ensureSignupPasswordPageReady() {
return { ready: true };
}
+103
View File
@@ -115,6 +115,7 @@ function fillInput(el, value) {
filledValues.push(value);
}
async function sleep() {}
async function waitForDocumentLoadComplete() {}
function isStep5Ready() { return false; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
@@ -177,6 +178,7 @@ function is405MethodNotAllowedPage() { return false; }
async function handle405ResendError() {}
function fillInput() {}
async function sleep() {}
async function waitForDocumentLoadComplete() {}
function isStep5Ready() { return true; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
@@ -336,6 +338,7 @@ function fillInput(el, value) {
filledValues.push({ target: el === nameInput ? 'name' : (el === ageInput ? 'age' : 'code'), value });
}
async function sleep() {}
async function waitForDocumentLoadComplete() {}
function isStep5Ready() { return true; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
@@ -531,6 +534,7 @@ function fillInput(el, value) {
el.value = value;
}
async function sleep() {}
async function waitForDocumentLoadComplete() {}
function isStep5Ready() { return false; }
function isStep8Ready() { return false; }
function isAddPhonePageReady() { return false; }
@@ -614,3 +618,102 @@ return {
assert.equal(snapshot.submitClicked, true);
assert.equal(snapshot.nameQueryCount >= 3, true);
});
test('prepareSignupVerificationFlow waits for complete verification page before reporting ready', async () => {
const api = new Function(`
const logs = [];
let now = 0;
let sleepCalls = 0;
let targetChecks = 0;
const location = {
href: 'https://auth.openai.com/email-verification',
pathname: '/email-verification',
};
const document = {
readyState: 'loading',
title: '',
body: {
textContent: 'Enter the verification code we just sent',
innerText: 'Enter the verification code we just sent',
},
querySelector(selector) {
if (selector === 'form[action*="email-verification" i]') {
return {};
}
return null;
},
querySelectorAll(selector) {
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
return [];
}
return [];
},
};
Date.now = () => now;
function throwIfStopped() {}
function log(message, level = 'info') { logs.push({ message, level }); }
async function sleep(ms = 0) {
sleepCalls += 1;
now += ms || 200;
if (sleepCalls >= 3) {
document.readyState = 'complete';
}
}
function isVisibleElement() { return true; }
function isActionEnabled() { return true; }
function getActionText(el) { return el?.textContent || ''; }
function getCurrentAuthRetryPageState() { return null; }
function isPhoneVerificationPageReady() { return false; }
function findResendVerificationCodeTrigger() { return null; }
function isEmailVerificationPage() { return true; }
function getPageTextSnapshot() { return document.body.textContent; }
function getVerificationCodeTarget() {
targetChecks += 1;
return document.readyState === 'complete'
? { type: 'single', element: { value: '' } }
: null;
}
function is405MethodNotAllowedPage() { return false; }
async function recoverCurrentAuthRetryPage() {}
function createSignupUserAlreadyExistsError() { return new Error('user already exists'); }
function getSignupPasswordInput() { return null; }
function getSignupPasswordSubmitButton() { return null; }
function isSignupEmailAlreadyExistsPage() { return false; }
function isSignupPasswordErrorPage() { return false; }
function getSignupPasswordTimeoutErrorPageState() { return null; }
function isStep5Ready() { return false; }
const VERIFICATION_PAGE_PATTERN = /verification code/i;
${extractFunction('getDocumentReadyStateSnapshot')}
${extractFunction('isDocumentLoadComplete')}
${extractFunction('waitForDocumentLoadComplete')}
${extractFunction('isSignupVerificationPageInteractiveReady')}
${extractFunction('isVerificationPageStillVisible')}
${extractFunction('isSignupProfilePageUrl')}
${extractFunction('isLikelyLoggedInChatgptHomeUrl')}
${extractFunction('getStep4PostVerificationState')}
${extractFunction('inspectSignupVerificationState')}
${extractFunction('waitForVerificationCodeTarget')}
${extractFunction('waitForSignupVerificationTransition')}
${extractFunction('prepareSignupVerificationFlow')}
return {
run() {
return prepareSignupVerificationFlow({ prepareLogLabel: '步骤 4 执行' }, 10000);
},
snapshot() {
return { logs, sleepCalls, targetChecks, readyState: document.readyState };
},
};
`)();
const result = await api.run();
const snapshot = api.snapshot();
assert.equal(result.ready, true);
assert.equal(snapshot.readyState, 'complete');
assert.equal(snapshot.sleepCalls >= 3, true);
assert.equal(snapshot.targetChecks >= 1, true);
});
+50
View File
@@ -92,6 +92,10 @@ function getLoginEmailInput() {
return ${JSON.stringify(overrides.emailInput || null)};
}
function getLoginPhoneInput() {
return ${JSON.stringify(overrides.phoneInput || null)};
}
function findOneTimeCodeLoginTrigger() {
return ${JSON.stringify(overrides.switchTrigger || null)};
}
@@ -100,6 +104,14 @@ function findLoginEntryTrigger() {
return ${JSON.stringify(overrides.loginEntryTrigger || null)};
}
function findLoginPhoneEntryTrigger() {
return ${JSON.stringify(overrides.phoneEntryTrigger || null)};
}
function findLoginMoreOptionsTrigger() {
return ${JSON.stringify(overrides.moreOptionsTrigger || null)};
}
function getLoginSubmitButton() {
return ${JSON.stringify(overrides.submitButton || null)};
}
@@ -112,6 +124,10 @@ function isAddPhonePageReady() {
return ${JSON.stringify(Boolean(overrides.addPhonePage))};
}
function isAddEmailPageReady() {
return ${JSON.stringify(Boolean(overrides.addEmailPage))};
}
function isVisibleElement() {
return true;
}
@@ -239,9 +255,43 @@ return {
assert.strictEqual(snapshot.state, 'entry_page');
}
{
const api = createApi({
phoneInput: { id: 'phone' },
submitButton: { id: 'submit' },
});
const snapshot = api.inspectLoginAuthState();
assert.strictEqual(snapshot.state, 'phone_entry_page');
}
{
const api = createApi({
pathname: '/add-email',
href: 'https://auth.openai.com/add-email',
emailInput: { id: 'email' },
submitButton: { id: 'submit' },
addEmailPage: true,
});
const snapshot = api.inspectLoginAuthState();
assert.strictEqual(snapshot.state, 'add_email_page');
assert.strictEqual(snapshot.addEmailPage, true);
}
assert.ok(
extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"),
'inspectLoginAuthState 应产出 oauth_consent_page 状态'
);
assert.ok(
extractFunction('inspectLoginAuthState').includes("state: 'phone_entry_page'"),
'inspectLoginAuthState 应产出 phone_entry_page 状态'
);
assert.ok(
extractFunction('inspectLoginAuthState').includes("state: 'add_email_page'"),
'inspectLoginAuthState 应产出 add_email_page 状态'
);
console.log('step6 login state tests passed');
+3 -3
View File
@@ -84,8 +84,8 @@ test('step6LoginFromPasswordPage switches to one-time-code login when password i
globalThis.normalizeStep6Snapshot = (value) => value;
globalThis.inspectLoginAuthState = () => snapshot;
globalThis.log = (message, level = 'info') => {
logs.push({ message, level });
globalThis.log = (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
};
globalThis.step6SwitchToOneTimeCodeLogin = async (payload, value) => {
assert.deepStrictEqual(payload, { email: 'user@example.com', password: '' });
@@ -110,7 +110,7 @@ test('step6LoginFromPasswordPage switches to one-time-code login when password i
assert.deepStrictEqual(result, { step6Outcome: 'success', via: 'switch_to_one_time_code_login' });
assert.deepStrictEqual(logs, [
{ message: '步骤 7当前未提供密码,改走一次性验证码登录。', level: 'warn' },
{ message: '当前未提供密码,改走一次性验证码登录。', level: 'warn', step: 7, stepKey: 'oauth-login' },
]);
} finally {
cleanupGlobals();
+636
View File
@@ -0,0 +1,636 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/signup-page.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 extractConst(name) {
const pattern = new RegExp(`const\\s+${name}\\s*=\\s*[\\s\\S]*?;`);
const match = source.match(pattern);
if (!match) {
throw new Error(`missing const ${name}`);
}
return match[0];
}
function createPhoneLoginEntryApi(options = {}) {
const {
href = 'https://auth.openai.com/log-in',
pathname = '/log-in',
inputAttributes = {},
inputRootText = '',
pageText = '',
addPhoneForm = false,
phoneUsernameKind = false,
} = options;
return new Function(`
${extractConst('ADD_PHONE_PAGE_PATTERN')}
${extractConst('LOGIN_PHONE_ENTRY_PAGE_PATTERN')}
const location = {
href: ${JSON.stringify(phoneUsernameKind ? `${href}${href.includes('?') ? '&' : '?'}usernameKind=phone_number` : href)},
pathname: ${JSON.stringify(pathname)},
};
const phoneInput = {
type: ${JSON.stringify(inputAttributes.type || 'text')},
maxLength: ${JSON.stringify(inputAttributes.maxLength ?? -1)},
getAttribute(name) {
return Object.prototype.hasOwnProperty.call(this.attributes, name) ? this.attributes[name] : '';
},
closest(selector) {
if (!${JSON.stringify(Boolean(inputRootText))}) return null;
if (String(selector || '').includes('fieldset') || String(selector || '').includes('div')) {
return { textContent: ${JSON.stringify(inputRootText)} };
}
return null;
},
attributes: ${JSON.stringify(inputAttributes)},
};
const form = ${addPhoneForm ? '{ textContent: "Add phone number" }' : 'null'};
const document = {
body: {
innerText: ${JSON.stringify(pageText || inputRootText)},
textContent: ${JSON.stringify(pageText || inputRootText)},
},
querySelector(selector) {
const text = String(selector || '');
if (text === 'form[action*="/add-phone" i]') return form;
return null;
},
querySelectorAll(selector) {
if (String(selector || '').includes('input')) return [phoneInput];
return [];
},
getElementById() {
return null;
},
};
const CSS = {
escape(value) {
return String(value || '');
},
};
function isVisibleElement(element) {
return Boolean(element);
}
function isPhoneVerificationPageReady() {
return false;
}
${extractFunction('getPageTextSnapshot')}
${extractFunction('isLoginPhoneUsernameKind')}
${extractFunction('isLoginPhoneEntryPageText')}
${extractFunction('isInsideHiddenPhoneControl')}
${extractFunction('summarizePhoneInputCandidate')}
${extractFunction('isUsablePhoneInputElement')}
${extractFunction('collectPhoneInputCandidates')}
${extractFunction('findUsablePhoneInput')}
${extractFunction('getLoginPhoneInput')}
${extractFunction('isAddPhonePageReady')}
return {
getLoginPhoneInput,
isAddPhonePageReady,
};
`)();
}
test('step 7 treats localized phone login entry as phone input instead of add-phone', () => {
const api = createPhoneLoginEntryApi({
inputRootText: '\u6b22\u8fce\u56de\u6765 \u7535\u8bdd\u53f7\u7801 +61 \u7ee7\u7eed \u8fd8\u6ca1\u6709\u5e10\u6237\uff1f\u8bf7\u6ce8\u518c',
});
assert.ok(api.getLoginPhoneInput(), 'localized phone login input should be detected');
assert.equal(api.isAddPhonePageReady(), false);
});
test('step 7 does not mistake email entry with a phone switch action for phone input', () => {
const api = createPhoneLoginEntryApi({
inputRootText: '\u7ee7\u7eed \u7ee7\u7eed\u4f7f\u7528\u624b\u673a\u767b\u5f55',
inputAttributes: { type: 'text', placeholder: '\u7535\u5b50\u90ae\u4ef6\u5730\u5740' },
});
assert.equal(api.getLoginPhoneInput(), null);
assert.equal(api.isAddPhonePageReady(), false);
});
test('step 7 detects username text input when usernameKind is phone_number', () => {
const api = createPhoneLoginEntryApi({
phoneUsernameKind: true,
pageText: '\u6b22\u8fce\u56de\u6765',
inputAttributes: { type: 'text', name: 'username', autocomplete: 'username' },
});
assert.ok(api.getLoginPhoneInput(), 'username text input should be treated as phone on phone login url');
});
test('step 7 ignores hidden phone inputs while resolving login phone entry', () => {
const api = createPhoneLoginEntryApi({
phoneUsernameKind: true,
pageText: '\u7535\u8bdd\u53f7\u7801',
inputAttributes: { type: 'hidden', name: 'phone' },
});
assert.equal(api.getLoginPhoneInput(), null);
});
test('add-phone detection stays true for real add-phone urls and forms', () => {
assert.equal(
createPhoneLoginEntryApi({
href: 'https://auth.openai.com/add-phone',
pathname: '/add-phone',
inputRootText: '\u7535\u8bdd\u53f7\u7801',
}).isAddPhonePageReady(),
true
);
assert.equal(
createPhoneLoginEntryApi({
addPhoneForm: true,
inputRootText: 'Add phone number',
}).isAddPhonePageReady(),
true
);
});
test('phone login switch waits longer for slow OpenAI entry transitions', () => {
assert.match(
extractFunction('switchFromEmailPageToPhoneLogin'),
/waitForPhoneLoginEntrySwitchTransition\(20000\)/
);
});
test('step 7 switches visible phone login country by provider dial code before filling number', async () => {
const api = new Function(`
const clicks = [];
let visibleCountryText = '\\u6fb3\\u5927\\u5229\\u4e9a (+61)';
let listboxOpen = false;
const phoneInput = {
closest() {
return null;
},
};
const countryButton = {
textContent: '',
querySelector(selector) {
if (selector === '.react-aria-SelectValue') {
return {
get textContent() {
return visibleCountryText;
},
};
}
return null;
},
};
const indonesiaOption = { textContent: '\\u5370\\u5ea6\\u5c3c\\u897f\\u4e9a +(62)' };
const unitedKingdomOption = { textContent: '\\u82f1\\u56fd +(44)' };
const document = {
querySelectorAll(selector) {
const text = String(selector || '');
if (text === '[role="listbox"] [role="option"], [role="option"]') {
return listboxOpen ? [indonesiaOption, unitedKingdomOption] : [];
}
if (text.includes('aria-haspopup="listbox"') || text.includes('aria-expanded')) {
return [countryButton];
}
if (text === 'select') {
return [];
}
return [];
},
querySelector(selector) {
const matches = this.querySelectorAll(selector);
return matches[0] || null;
},
};
function isVisibleElement(element) {
return Boolean(element);
}
function getActionText(element) {
if (element === countryButton) return visibleCountryText;
return String(element?.textContent || '').replace(/\\s+/g, ' ').trim();
}
function getPageTextSnapshot() {
return visibleCountryText;
}
function simulateClick(target) {
clicks.push(getActionText(target));
if (target === countryButton) {
listboxOpen = true;
}
if (target === unitedKingdomOption) {
visibleCountryText = '\\u82f1\\u56fd +(44)';
listboxOpen = false;
}
}
async function sleep() {}
function throwIfStopped() {}
${extractFunction('normalizePhoneDigits')}
${extractFunction('extractDialCodeFromText')}
${extractFunction('dispatchSignupPhoneFieldEvents')}
${extractFunction('normalizeSignupCountryLabel')}
${extractFunction('getSignupCountryLabelAliases')}
${extractFunction('getSignupPhoneOptionLabel')}
${extractFunction('normalizeSignupCountryOptionValue')}
${extractFunction('getSignupRegionDisplayName')}
${extractFunction('getSignupPhoneCountryMatchLabels')}
${extractFunction('isSameSignupCountryOption')}
${extractFunction('getSignupPhoneInput')}
${extractFunction('getSignupPhoneControlRoots')}
${extractFunction('querySignupPhoneCountryElements')}
${extractFunction('isSignupPhoneCountrySelect')}
${extractFunction('getSignupPhoneCountrySelect')}
${extractFunction('getSignupPhoneSelectedCountryOption')}
${extractFunction('getSignupPhoneCountryButtonText')}
${extractFunction('getSignupPhoneCountryButton')}
${extractFunction('getSignupPhoneDisplayedDialCode')}
${extractFunction('resolveSignupPhoneDialCodeFromNumber')}
${extractFunction('resolveSignupPhoneTargetDialCode')}
${extractFunction('getSignupPhoneCountryTargetLabels')}
${extractFunction('doesSignupPhoneCountryTextMatchTarget')}
${extractFunction('isSignupPhoneCountrySelectionSynced')}
${extractFunction('findSignupPhoneCountryOptionByLabel')}
${extractFunction('findSignupPhoneCountryOptionByPhoneNumber')}
${extractFunction('trySelectSignupPhoneCountryOption')}
${extractFunction('getVisibleSignupPhoneCountryListboxOptions')}
${extractFunction('findSignupPhoneCountryListboxOption')}
${extractFunction('trySelectSignupPhoneCountryListboxOption')}
${extractFunction('ensureSignupPhoneCountrySelected')}
function getLoginPhoneCountrySelect() { return null; }
function getLoginPhoneCountryOptionLabel() { return ''; }
${extractFunction('selectCountryForPhoneInput')}
return {
async run() {
return selectCountryForPhoneInput(phoneInput, '447423278610', '', { visibleStep: 7 });
},
getClicks() {
return clicks.slice();
},
getVisibleCountryText() {
return visibleCountryText;
},
};
`)();
const dialCode = await api.run();
assert.equal(dialCode, '44');
assert.equal(api.getVisibleCountryText(), '\u82f1\u56fd +(44)');
assert.deepEqual(api.getClicks(), ['\u6fb3\u5927\u5229\u4e9a (+61)', '\u82f1\u56fd +(44)']);
});
test('step 7 scrolls the phone login country listbox until the dial code option is rendered', async () => {
const api = new Function(`
const clicks = [];
const scrollEvents = [];
let visibleCountryText = '\\u6fb3\\u5927\\u5229\\u4e9a (+61)';
let listboxOpen = false;
let ukRendered = false;
const phoneInput = {
closest() {
return null;
},
};
const countryButton = {
textContent: '',
querySelector(selector) {
if (selector === '.react-aria-SelectValue') {
return {
get textContent() {
return visibleCountryText;
},
};
}
return null;
},
};
const listbox = {
scrollTop: 0,
scrollHeight: 3200,
clientHeight: 420,
dispatchEvent(event) {
scrollEvents.push({ type: event?.type || '', scrollTop: this.scrollTop });
if (this.scrollTop >= 900) {
ukRendered = true;
}
return true;
},
};
const albaniaOption = { textContent: '\\u963f\\u5c14\\u5df4\\u5c3c\\u4e9a +(355)', parentElement: listbox };
const unitedKingdomOption = { textContent: '\\u82f1\\u56fd +(44)', parentElement: listbox };
const document = {
body: {},
documentElement: {},
querySelectorAll(selector) {
const text = String(selector || '');
if (text === '[role="listbox"]') {
return listboxOpen ? [listbox] : [];
}
if (text === '[role="listbox"] [role="option"], [role="option"]') {
if (!listboxOpen) return [];
return ukRendered ? [albaniaOption, unitedKingdomOption] : [albaniaOption];
}
if (text.includes('aria-haspopup="listbox"') || text.includes('aria-expanded')) {
return [countryButton];
}
if (text === 'select') {
return [];
}
return [];
},
querySelector(selector) {
const items = this.querySelectorAll(selector);
return items[0] || null;
},
};
function isVisibleElement(element) {
return Boolean(element);
}
function getActionText(element) {
if (element === countryButton) return visibleCountryText;
return String(element?.textContent || '').replace(/\\s+/g, ' ').trim();
}
function getPageTextSnapshot() {
return visibleCountryText;
}
function simulateClick(target) {
clicks.push(getActionText(target));
if (target === countryButton) {
listboxOpen = true;
}
if (target === unitedKingdomOption) {
visibleCountryText = '\\u82f1\\u56fd +(44)';
listboxOpen = false;
}
}
async function sleep() {}
function throwIfStopped() {}
${extractFunction('normalizePhoneDigits')}
${extractFunction('extractDialCodeFromText')}
${extractFunction('dispatchSignupPhoneFieldEvents')}
${extractFunction('normalizeSignupCountryLabel')}
${extractFunction('getSignupCountryLabelAliases')}
${extractFunction('getSignupPhoneOptionLabel')}
${extractFunction('normalizeSignupCountryOptionValue')}
${extractFunction('getSignupRegionDisplayName')}
${extractFunction('getSignupPhoneCountryMatchLabels')}
${extractFunction('isSameSignupCountryOption')}
${extractFunction('getSignupPhoneInput')}
${extractFunction('getSignupPhoneControlRoots')}
${extractFunction('querySignupPhoneCountryElements')}
${extractFunction('isSignupPhoneCountrySelect')}
${extractFunction('getSignupPhoneCountrySelect')}
${extractFunction('getSignupPhoneSelectedCountryOption')}
${extractFunction('getSignupPhoneCountryButtonText')}
${extractFunction('getSignupPhoneCountryButton')}
${extractFunction('getSignupPhoneDisplayedDialCode')}
${extractFunction('resolveSignupPhoneDialCodeFromNumber')}
${extractFunction('resolveSignupPhoneTargetDialCode')}
${extractFunction('getSignupPhoneCountryTargetLabels')}
${extractFunction('doesSignupPhoneCountryTextMatchTarget')}
${extractFunction('isSignupPhoneCountrySelectionSynced')}
${extractFunction('findSignupPhoneCountryOptionByLabel')}
${extractFunction('findSignupPhoneCountryOptionByPhoneNumber')}
${extractFunction('trySelectSignupPhoneCountryOption')}
${extractFunction('getVisibleSignupPhoneCountryListboxOptions')}
${extractFunction('findSignupPhoneCountryListboxOption')}
${extractFunction('trySelectSignupPhoneCountryListboxOption')}
${extractFunction('ensureSignupPhoneCountrySelected')}
function getLoginPhoneCountrySelect() { return null; }
function getLoginPhoneCountryOptionLabel() { return ''; }
${extractFunction('selectCountryForPhoneInput')}
return {
async run() {
return selectCountryForPhoneInput(phoneInput, '447799342687', '', { visibleStep: 7 });
},
getClicks() {
return clicks.slice();
},
getScrollEvents() {
return scrollEvents.slice();
},
getVisibleCountryText() {
return visibleCountryText;
},
};
`)();
const dialCode = await api.run();
assert.equal(dialCode, '44');
assert.equal(api.getVisibleCountryText(), '\u82f1\u56fd +(44)');
assert.equal(api.getScrollEvents().length > 0, true);
assert.deepEqual(api.getClicks(), ['\u6fb3\u5927\u5229\u4e9a (+61)', '\u82f1\u56fd +(44)']);
});
function createPhoneFillApi(fillBehavior, options = {}) {
const {
initialValue = '+44',
} = options;
return new Function('fillBehavior', `
const fills = [];
const phoneInput = {
value: ${JSON.stringify(initialValue)},
form: null,
getAttribute(name) {
return name === 'value' ? this.value : '';
},
focus() {},
closest() {
return this.form;
},
};
const hiddenPhoneInput = {
type: 'hidden',
value: '',
events: [],
getAttribute(name) {
if (name === 'type') return 'hidden';
if (name === 'name') return 'phone';
return '';
},
dispatchEvent(event) {
this.events.push(event.type);
},
};
const root = {
querySelectorAll(selector) {
if (String(selector).includes('input[name="phone"]')) {
return [hiddenPhoneInput];
}
return [];
},
};
phoneInput.form = root;
function fillInput(input, value) {
fills.push(value);
fillBehavior(input, value);
}
async function sleep() {}
function throwIfStopped() {}
function isVisibleElement() { return false; }
function log() {}
${extractFunction('normalizePhoneDigits')}
${extractFunction('toNationalPhoneNumber')}
${extractFunction('toE164PhoneNumber')}
${extractFunction('getPhoneInputRenderedValue')}
${extractFunction('isPhoneInputValueVerified')}
${extractFunction('waitForPhoneInputValue')}
${extractFunction('formatPhoneHiddenFormValue')}
${extractFunction('getPhoneHiddenValueInput')}
${extractFunction('setPhoneHiddenValue')}
${extractFunction('syncPhoneHiddenFormValue')}
${extractFunction('isPhoneInputValueComplete')}
${extractFunction('getLoginPhoneFillCandidates')}
${extractFunction('getLoginPhoneInputDiagnostics')}
${extractFunction('getLoginPhoneHiddenValueDiagnostics')}
${extractFunction('fillLoginPhoneInputAndConfirm')}
return {
run() {
return fillLoginPhoneInputAndConfirm(phoneInput, {
phoneNumber: '447780579093',
dialCode: '44',
visibleStep: 7,
maxAttempts: 2,
});
},
getFills() {
return fills.slice();
},
getValue() {
return phoneInput.value;
},
getHiddenValue() {
return hiddenPhoneInput.value;
},
getHiddenEvents() {
return hiddenPhoneInput.events.slice();
},
};
`)(fillBehavior);
}
test('step 7 keeps visible dial prefix when filling phone login and syncs the hidden value', async () => {
const api = createPhoneFillApi((input, value) => {
input.value = value;
});
const result = await api.run();
assert.equal(result.inputValue, '7780579093');
assert.equal(result.attemptedValue, '+447780579093');
assert.equal(api.getValue(), '+447780579093');
assert.equal(api.getHiddenValue(), '+447780579093');
assert.deepEqual(api.getHiddenEvents(), ['input', 'change']);
assert.deepEqual(api.getFills(), ['+447780579093']);
});
test('step 7 keeps national phone fill when visible login input has no dial prefix', async () => {
const api = createPhoneFillApi((input, value) => {
input.value = value;
}, { initialValue: '' });
const result = await api.run();
assert.equal(result.inputValue, '7780579093');
assert.equal(result.attemptedValue, '7780579093');
assert.equal(api.getValue(), '7780579093');
assert.equal(api.getHiddenValue(), '+447780579093');
assert.deepEqual(api.getHiddenEvents(), ['input', 'change']);
assert.deepEqual(api.getFills(), ['7780579093']);
});
test('step 7 stops before submit when phone fill never includes the local number', async () => {
const api = createPhoneFillApi((input) => {
input.value = '+44';
});
await assert.rejects(api.run, /7780579093/);
assert.equal(api.getValue(), '+44');
assert.deepEqual(api.getFills(), ['+447780579093', '7780579093', '+447780579093', '7780579093']);
});
+34 -1
View File
@@ -115,6 +115,37 @@ return {
);
});
test('ensureStep8VerificationPageReady allows add-email handoff only when requested', async () => {
const api = new Function(`
function getLoginAuthStateLabel(state) {
return state === 'add_email_page' ? '添加邮箱页' : 'unknown page';
}
async function getLoginAuthStateFromContent() {
return {
state: 'add_email_page',
url: 'https://auth.openai.com/add-email',
};
}
${extractFunction(backgroundSource, 'ensureStep8VerificationPageReady')}
return {
run(options) {
return ensureStep8VerificationPageReady(options || {});
},
};
`)();
await assert.rejects(
() => api.run({}),
/当前未进入登录验证码页面/
);
const result = await api.run({ allowAddEmailPage: true });
assert.equal(result.state, 'add_email_page');
});
test('step 8 reruns step 7 when auth page enters login timeout retry state', async () => {
const calls = {
rerunStep7: 0,
@@ -185,7 +216,9 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
assert.equal(calls.logs.some(({ message }) => /重新开始|重新发起/.test(message)), true);
assert.deepStrictEqual(calls.rerunOptions, [
{
logMessage: '步骤 8认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...',
logMessage: '认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...',
logStep: 8,
logStepKey: 'fetch-login-code',
},
]);
});
+21 -4
View File
@@ -2,6 +2,7 @@ const assert = require('assert');
const fs = require('fs');
const helperSource = fs.readFileSync('background.js', 'utf8');
const messageRouterSource = fs.readFileSync('background/message-router.js', 'utf8');
const tabRuntimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8');
function extractFunction(source, name) {
@@ -54,10 +55,9 @@ const helperBundle = [
extractFunction(helperSource, 'isGeneratedAliasProvider'),
extractFunction(helperSource, 'shouldUseCustomRegistrationEmail'),
extractFunction(helperSource, 'isLocalhostOAuthCallbackUrl'),
extractFunction(helperSource, 'handleStepData'),
].join('\n');
const api = new Function('tabRuntimeSource', `
const api = new Function('messageRouterSource', 'tabRuntimeSource', `
const self = {};
const HOTMAIL_PROVIDER = 'hotmail-api';
const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
@@ -116,6 +116,7 @@ async function addLog(message) {
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
async function markCurrentRegistrationAccountUsed() {}
function matchesSourceUrlFamily() {
return false;
}
@@ -149,9 +150,25 @@ const closeLocalhostCallbackTabs = tabRuntime.closeLocalhostCallbackTabs;
const isLocalhostOAuthCallbackTabMatch = tabRuntime.isLocalhostOAuthCallbackTabMatch;
const buildLocalhostCleanupPrefix = tabRuntime.buildLocalhostCleanupPrefix;
const closeTabsByUrlPrefix = tabRuntime.closeTabsByUrlPrefix;
${messageRouterSource}
const messageRouter = self.MultiPageBackgroundMessageRouter.createMessageRouter({
addLog,
buildLocalhostCleanupPrefix,
closeLocalhostCallbackTabs,
closeTabsByUrlPrefix,
finalizeIcloudAliasAfterSuccessfulFlow,
finalizePhoneActivationAfterSuccessfulFlow,
getCurrentLuckmailPurchase: () => null,
getState,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }),
isHotmailProvider: () => false,
isLuckmailProvider,
markCurrentRegistrationAccountUsed,
patchHotmailAccount: async () => {},
});
return {
handleStepData,
handleStepData: messageRouter.handleStepData,
closeLocalhostCallbackTabs,
isLocalhostOAuthCallbackTabMatch,
reset({ tabs, tabRegistry }) {
@@ -170,7 +187,7 @@ return {
};
},
};
`)(tabRuntimeSource);
`)(messageRouterSource, tabRuntimeSource);
(async () => {
const codexCallbackUrl = 'http://127.0.0.1:8317/codex/callback?code=abc&state=xyz';
+34 -1
View File
@@ -79,7 +79,10 @@ function createSub2ApiPanelContext(fetchCalls = []) {
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createJsonResponse({
code: 0,
data: [{ id: 5, name: 'codex', platform: 'openai' }],
data: [
{ id: 5, name: 'codex', platform: 'openai' },
{ id: 9, name: 'codex-plus', platform: 'openai' },
],
});
}
if (parsed.pathname === '/api/v1/admin/proxies/all') {
@@ -220,6 +223,36 @@ test('SUB2API step 1 omits proxy_id when default proxy is empty', async () => {
assert.equal(Object.hasOwn(generateCall.body, 'proxy_id'), false);
});
test('SUB2API step 10 creates accounts in multiple configured groups', async () => {
const fetchCalls = [];
const context = createSub2ApiPanelContext(fetchCalls);
const step1Result = await vm.runInContext(`
step1_generateOpenAiAuthUrl({
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex, codex-plus'
}, { report: false })
`, context);
await vm.runInContext(`
step9_submitOpenAiCallback({
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex, codex-plus',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
sub2apiGroupIds: ${JSON.stringify(step1Result.sub2apiGroupIds)}
})
`, context);
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
assert.deepEqual(Array.from(step1Result.sub2apiGroupIds), [5, 9]);
assert.deepEqual(createCall.body.group_ids, [5, 9]);
});
test('SUB2API step 10 omits proxy_id when no proxy is configured', async () => {
const fetchCalls = [];
const context = createSub2ApiPanelContext(fetchCalls);
+54
View File
@@ -20,6 +20,9 @@ function createUpdateService(options = {}) {
setItem(key, value) {
cache.set(key, String(value));
},
removeItem(key) {
cache.delete(key);
},
};
if (options.cachedSnapshot) {
@@ -180,3 +183,54 @@ test('getReleaseSnapshot reorders cached releases before choosing latest version
['Ultra1.1']
);
});
test('getReleaseSnapshot suppresses an ignored latest update until a newer release appears', async () => {
let releases = [
{
tag_name: 'Ultra1.1',
name: 'Ultra1.1',
html_url: 'https://example.com/Ultra1.1',
published_at: '2026-04-19T00:00:00.000Z',
body: '- current release',
draft: false,
prerelease: false,
},
];
const { api } = createUpdateService({
manifest: {
version: '1.0',
version_name: 'Ultra1.0',
},
fetchImpl: async () => ({
ok: true,
async json() {
return releases;
},
}),
});
const firstSnapshot = await api.getReleaseSnapshot({ force: true });
assert.equal(firstSnapshot.status, 'update-available');
assert.equal(api.ignoreReleaseSnapshot(firstSnapshot), 'Ultra1.1');
const ignoredSnapshot = await api.getReleaseSnapshot({ force: true });
assert.equal(ignoredSnapshot.status, 'ignored');
assert.equal(ignoredSnapshot.ignoredVersion, 'Ultra1.1');
releases = [
{
tag_name: 'Ultra1.2',
name: 'Ultra1.2',
html_url: 'https://example.com/Ultra1.2',
published_at: '2026-04-20T00:00:00.000Z',
body: '- next release',
draft: false,
prerelease: false,
},
...releases,
];
const newerSnapshot = await api.getReleaseSnapshot({ force: true });
assert.equal(newerSnapshot.status, 'update-available');
assert.equal(newerSnapshot.latestVersion, 'Ultra1.2');
});