fix: resolve step 9 profile fallback with latest dev
This commit is contained in:
@@ -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}$/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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: '步骤 7:contributionMode=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: '步骤 7:contributionMode=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), '');
|
||||
});
|
||||
|
||||
@@ -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 授权页');
|
||||
});
|
||||
|
||||
@@ -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: '步骤 10:OAuth 账号 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: '步骤 10:CPA 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, /步骤 10:CPA 接口提交失败: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 = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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|超时/);
|
||||
});
|
||||
@@ -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,状态 active,flow_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已经绑了订阅,需要手动解绑'
|
||||
);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 所在 iframe(frameId=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);
|
||||
});
|
||||
|
||||
@@ -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: '',
|
||||
}),
|
||||
/缺少卡密/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user