Merge branch 'dev' into icloud-forward-mailbox-config
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
test('address sources normalize supported countries and return local seeds', () => {
|
||||
const source = fs.readFileSync('data/address-sources.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageAddressSources;`)(globalScope);
|
||||
|
||||
assert.equal(api.normalizeCountryCode('Deutschland'), 'DE');
|
||||
assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU');
|
||||
assert.equal(api.normalizeCountryCode('unknown'), '');
|
||||
|
||||
const deSeed = api.getAddressSeedForCountry('DE');
|
||||
assert.equal(deSeed.countryCode, 'DE');
|
||||
assert.equal(deSeed.suggestionIndex, 1);
|
||||
assert.equal(Boolean(deSeed.query), true);
|
||||
assert.equal(Boolean(deSeed.fallback.city), true);
|
||||
|
||||
const fallbackSeed = api.getAddressSeedForCountry('unknown', { fallbackCountry: 'AU' });
|
||||
assert.equal(fallbackSeed.countryCode, 'AU');
|
||||
assert.equal(fallbackSeed.fallback.region, 'New South Wales');
|
||||
});
|
||||
@@ -45,11 +45,15 @@ const bundle = [
|
||||
'const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0;',
|
||||
'const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600;',
|
||||
'const PERSISTED_SETTING_DEFAULTS = { autoStepDelaySeconds: null };',
|
||||
"const AUTO_RUN_PRE_EXECUTION_DELAYS_BY_STEP_KEY = new Map([['plus-checkout-create', 20000]]);",
|
||||
'function getStepDefinitionForState(step, state = {}) { return state.definitions?.[step] || null; }',
|
||||
extractFunction('normalizeAutoStepDelaySeconds'),
|
||||
extractFunction('resolveLegacyAutoStepDelaySeconds'),
|
||||
extractFunction('getStepExecutionKeyForState'),
|
||||
extractFunction('getAutoRunPreExecutionDelayMs'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds };`)();
|
||||
const api = new Function(`${bundle}; return { normalizeAutoStepDelaySeconds, resolveLegacyAutoStepDelaySeconds, getAutoRunPreExecutionDelayMs };`)();
|
||||
|
||||
assert.strictEqual(
|
||||
api.normalizeAutoStepDelaySeconds(''),
|
||||
@@ -123,4 +127,24 @@ assert.strictEqual(
|
||||
'empty legacy settings should migrate to no delay'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
api.getAutoRunPreExecutionDelayMs(6, {
|
||||
definitions: {
|
||||
6: { key: 'plus-checkout-create' },
|
||||
},
|
||||
}),
|
||||
20000,
|
||||
'Plus checkout create should wait before step execution'
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
api.getAutoRunPreExecutionDelayMs(6, {
|
||||
definitions: {
|
||||
6: { key: 'clear-login-cookies' },
|
||||
},
|
||||
}),
|
||||
0,
|
||||
'normal step 6 should not inherit the Plus checkout pre-wait'
|
||||
);
|
||||
|
||||
console.log('auto step delay tests passed');
|
||||
|
||||
@@ -87,6 +87,8 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
totalRuns: 10,
|
||||
attemptRun: 3,
|
||||
},
|
||||
plusModeEnabled: false,
|
||||
contributionMode: false,
|
||||
});
|
||||
|
||||
const appended = await helpers.appendAccountRunRecord('step8_failed', null, '步骤 8:认证页进入了手机号页面,当前不是 OAuth 同意页,无法继续自动授权。');
|
||||
@@ -138,6 +140,39 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
assert.equal(normalizedStoppedRecord.failedStep, 7);
|
||||
});
|
||||
|
||||
test('account run history records preserve Plus and contribution mode flags', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
chrome: { storage: { local: { get: async () => ({}), set: async () => {} } } },
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
const record = helpers.buildAccountRunHistoryRecord({
|
||||
email: 'plus@example.com',
|
||||
password: 'secret',
|
||||
plusModeEnabled: true,
|
||||
contributionMode: true,
|
||||
}, 'success');
|
||||
|
||||
assert.equal(record.plusModeEnabled, true);
|
||||
assert.equal(record.contributionMode, true);
|
||||
|
||||
const normalized = helpers.normalizeAccountRunHistoryRecord({
|
||||
email: 'plus@example.com',
|
||||
password: 'secret',
|
||||
finalStatus: 'success',
|
||||
plusModeEnabled: true,
|
||||
contributionMode: true,
|
||||
});
|
||||
|
||||
assert.equal(normalized.plusModeEnabled, true);
|
||||
assert.equal(normalized.contributionMode, true);
|
||||
});
|
||||
|
||||
test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -118,6 +118,9 @@ function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
getStepDefinition(step) {
|
||||
return { id: step, key: 'test-step' };
|
||||
},
|
||||
async executeStep(step) {
|
||||
events.registryCalls.push(step);
|
||||
if (step === 8) {
|
||||
@@ -127,6 +130,12 @@ const stepRegistry = {
|
||||
}
|
||||
},
|
||||
};
|
||||
function getStepRegistryForState() {
|
||||
return stepRegistry;
|
||||
}
|
||||
function getStepDefinitionForState(step) {
|
||||
return { id: step, key: 'test-step' };
|
||||
}
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
@@ -212,10 +221,19 @@ function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
getStepDefinition(step) {
|
||||
return { id: step, key: 'test-step' };
|
||||
},
|
||||
async executeStep() {
|
||||
throw new Error('BROWSER_SWITCH_REQUIRED::请更换浏览器进行注册登录。');
|
||||
},
|
||||
};
|
||||
function getStepRegistryForState() {
|
||||
return stepRegistry;
|
||||
}
|
||||
function getStepDefinitionForState(step) {
|
||||
return { id: step, key: 'test-step' };
|
||||
}
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
|
||||
@@ -84,14 +84,16 @@ test('contribution oauth module exposes a factory', () => {
|
||||
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
|
||||
});
|
||||
|
||||
test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => {
|
||||
test('buildContributionModeState preserves active contribution runtime while keeping contribution on sub2api', () => {
|
||||
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
|
||||
|
||||
const api = new Function(`
|
||||
const api = new Function(`
|
||||
const DEFAULT_STATE = { panelMode: 'cpa' };
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -107,6 +109,35 @@ const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
function isPlusModeState(state = {}) { return Boolean(state?.plusModeEnabled); }
|
||||
function normalizeContributionModeSource(value = '') {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
return normalized === 'sub2api' ? 'sub2api' : 'cpa';
|
||||
}
|
||||
function resolveContributionModeRoutingState(state = {}) {
|
||||
const currentStatus = String(state?.contributionStatus || '').trim().toLowerCase();
|
||||
const currentSource = normalizeContributionModeSource(state?.contributionSource);
|
||||
const hasActiveSession = Boolean(
|
||||
String(state?.contributionSessionId || '').trim()
|
||||
&& currentStatus
|
||||
&& !['auto_approved', 'auto_rejected', 'expired', 'error'].includes(currentStatus)
|
||||
);
|
||||
if (hasActiveSession) {
|
||||
return {
|
||||
source: currentSource,
|
||||
targetGroupName: currentSource === 'sub2api'
|
||||
? (String(state?.contributionTargetGroupName || '').trim() || 'codex号池')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
const source = 'sub2api';
|
||||
return {
|
||||
source,
|
||||
targetGroupName: isPlusModeState(state)
|
||||
? 'openai-plus'
|
||||
: (String(state?.contributionTargetGroupName || '').trim() || 'codex号池'),
|
||||
};
|
||||
}
|
||||
${bundle}
|
||||
return { buildContributionModeState };
|
||||
`)();
|
||||
@@ -125,6 +156,8 @@ return { buildContributionModeState };
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: 'session-001',
|
||||
@@ -138,7 +171,7 @@ return { buildContributionModeState };
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
@@ -157,6 +190,8 @@ return { buildContributionModeState };
|
||||
{
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -175,6 +210,37 @@ return { buildContributionModeState };
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(true, {
|
||||
panelMode: 'cpa',
|
||||
plusModeEnabled: true,
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {}),
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'openai-plus',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'sub2api',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('resetState preserves contribution runtime across reset', () => {
|
||||
@@ -334,6 +400,8 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
let statusPollCount = 0;
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
email: 'user@example.com',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
@@ -424,6 +492,8 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"nickname":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"email":"user@example\.com"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"source":"sub2api"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"target_group_name":"codex号池"/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
@@ -471,6 +541,78 @@ test('contribution oauth manager accepts localhost callback urls that contain er
|
||||
);
|
||||
});
|
||||
|
||||
test('contribution oauth manager switches Plus contribution traffic to sub2api openai-plus', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
plusModeEnabled: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'openai-plus',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/start')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-plus-001',
|
||||
state: 'oauth-state-plus-001',
|
||||
source: 'sub2api',
|
||||
target_group_name: 'openai-plus',
|
||||
auth_url: 'https://auth.example.com/oauth?state=oauth-state-plus-001',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-plus-001',
|
||||
status: 'waiting',
|
||||
source: 'sub2api',
|
||||
target_group_name: 'openai-plus',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, { ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
chrome: {
|
||||
tabs: {
|
||||
async create(payload) {
|
||||
return { id: 91, url: payload.url };
|
||||
},
|
||||
async update() {
|
||||
return null;
|
||||
},
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
broadcastDataUpdate: (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
await manager.startContributionFlow();
|
||||
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"source":"sub2api"/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"target_group_name":"openai-plus"/);
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
@@ -44,4 +44,5 @@ 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('oauth_consent_page'), 'OAuth 授权页');
|
||||
});
|
||||
|
||||
@@ -59,6 +59,8 @@ function createRouter(overrides = {}) {
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getSourceLabel: () => '',
|
||||
getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
|
||||
getStepDefinitionForState: overrides.getStepDefinitionForState,
|
||||
getStepIdsForState: overrides.getStepIdsForState,
|
||||
getTabId: overrides.getTabId || (async () => null),
|
||||
getStopRequested: () => false,
|
||||
handleAutoRunLoopUnhandledError: async () => {},
|
||||
@@ -184,6 +186,49 @@ test('message router skips step 5 when step 4 reports already logged-in transiti
|
||||
assert.equal(events.logs[0]?.message, '步骤 4:检测到账号已直接进入已登录态,已自动跳过步骤 5。');
|
||||
});
|
||||
|
||||
test('message router skips login-code step when oauth login lands on consent page', async () => {
|
||||
const stepKeys = {
|
||||
7: 'oauth-login',
|
||||
8: 'fetch-login-code',
|
||||
9: 'confirm-oauth',
|
||||
};
|
||||
const { router, events } = createRouter({
|
||||
state: { stepStatuses: { 7: 'completed', 8: 'pending', 9: 'pending' } },
|
||||
getStepDefinitionForState: (step) => ({ id: step, key: stepKeys[step] || '' }),
|
||||
getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
|
||||
});
|
||||
|
||||
await router.handleStepData(7, {
|
||||
skipLoginVerificationStep: true,
|
||||
directOAuthConsentPage: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.stepStatuses, [{ step: 8, status: 'skipped' }]);
|
||||
assert.equal(events.logs.some(({ message }) => /OAuth 授权页.*步骤 8/.test(message)), true);
|
||||
});
|
||||
|
||||
test('message router skips Plus login-code step when oauth login lands on consent page', async () => {
|
||||
const stepKeys = {
|
||||
10: 'oauth-login',
|
||||
11: 'fetch-login-code',
|
||||
12: 'confirm-oauth',
|
||||
13: 'platform-verify',
|
||||
};
|
||||
const { router, events } = createRouter({
|
||||
state: { plusModeEnabled: true, stepStatuses: { 10: 'completed', 11: 'pending', 12: 'pending', 13: 'pending' } },
|
||||
getStepDefinitionForState: (step) => ({ id: step, key: stepKeys[step] || '' }),
|
||||
getStepIdsForState: () => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
|
||||
});
|
||||
|
||||
await router.handleStepData(10, {
|
||||
skipLoginVerificationStep: true,
|
||||
directOAuthConsentPage: true,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.stepStatuses, [{ step: 11, status: 'skipped' }]);
|
||||
assert.equal(events.logs.some(({ message }) => /OAuth 授权页.*步骤 11/.test(message)), true);
|
||||
});
|
||||
|
||||
test('message router finalizes step 3 before marking it completed', async () => {
|
||||
const { router, events } = createRouter();
|
||||
|
||||
|
||||
@@ -7,5 +7,10 @@ test('background imports step registry and shared step definitions', () => {
|
||||
assert.match(source, /background\/steps\/registry\.js/);
|
||||
assert.match(source, /data\/step-definitions\.js/);
|
||||
assert.match(source, /MultiPageStepDefinitions\?\.getSteps/);
|
||||
assert.match(source, /stepRegistry\.executeStep\(step,\s*state\)/);
|
||||
assert.match(source, /getStepRegistryForState\(state\)/);
|
||||
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\/paypal-approve\.js/);
|
||||
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
|
||||
});
|
||||
|
||||
@@ -178,6 +178,55 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 forwards direct OAuth consent skip metadata when completing', 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 = {
|
||||
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 () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => ({
|
||||
step6Outcome: 'success',
|
||||
state: 'oauth_consent_page',
|
||||
skipLoginVerificationStep: true,
|
||||
directOAuthConsentPage: true,
|
||||
}),
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await executor.executeStep7({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
visibleStep: 10,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.completions, [
|
||||
{
|
||||
step: 10,
|
||||
payload: {
|
||||
loginVerificationRequestedAt: null,
|
||||
skipLoginVerificationStep: true,
|
||||
directOAuthConsentPage: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is missing', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
@@ -85,8 +85,78 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
{ step8VerificationTargetEmail: 'display.user@example.com' },
|
||||
]);
|
||||
assert.deepStrictEqual(calls.ensureReadyOptions, [
|
||||
{ timeoutMs: 5000 },
|
||||
{ visibleStep: 8, authLoginStep: 7, timeoutMs: 5000 },
|
||||
]);
|
||||
assert.equal(calls.resolveOptions.completionStep, 8);
|
||||
});
|
||||
|
||||
test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => {
|
||||
let resolvedStep = null;
|
||||
let resolvedOptions = null;
|
||||
let readyOptions = null;
|
||||
const remainingStepCalls = [];
|
||||
|
||||
const executor = api.createStep8Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async (options) => {
|
||||
readyOptions = options;
|
||||
return { state: 'verification_page', displayedEmail: 'plus.user@example.com' };
|
||||
},
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async (details) => {
|
||||
remainingStepCalls.push(details.step);
|
||||
return 9000;
|
||||
},
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs, details) => {
|
||||
remainingStepCalls.push(details.step);
|
||||
return Math.min(defaultTimeoutMs, 9000);
|
||||
},
|
||||
getMailConfig: () => ({
|
||||
provider: 'qq',
|
||||
label: 'QQ 邮箱',
|
||||
source: 'mail-qq',
|
||||
url: 'https://mail.qq.com',
|
||||
navigateOnReuse: false,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret', plusModeEnabled: true }),
|
||||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
isVerificationMailPollingError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
resolveVerificationStep: async (step, _state, _mail, options) => {
|
||||
resolvedStep = step;
|
||||
resolvedOptions = options;
|
||||
await options.getRemainingTimeMs({ actionLabel: '登录验证码流程' });
|
||||
},
|
||||
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: 11,
|
||||
plusModeEnabled: true,
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
});
|
||||
|
||||
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(remainingStepCalls, [11, 11]);
|
||||
});
|
||||
|
||||
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
|
||||
|
||||
@@ -80,3 +80,15 @@ return { detectScriptSource };
|
||||
'mail-163'
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => {
|
||||
const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n');
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { shouldReportReadyForFrame };
|
||||
`)();
|
||||
|
||||
assert.equal(api.shouldReportReadyForFrame('plus-checkout', true), false);
|
||||
assert.equal(api.shouldReportReadyForFrame('plus-checkout', false), true);
|
||||
assert.equal(api.shouldReportReadyForFrame('paypal-flow', true), true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8');
|
||||
|
||||
function loadModule() {
|
||||
const self = {};
|
||||
return new Function('self', `${source}; return self.MultiPageBackgroundPayPalApprove;`)(self);
|
||||
}
|
||||
|
||||
function createExecutor({
|
||||
pageStates,
|
||||
submitResults,
|
||||
tabUrls = [],
|
||||
getTabId = async (source) => (source === 'paypal-flow' ? 1 : null),
|
||||
isTabAlive = async () => true,
|
||||
queryTabs = [],
|
||||
}) {
|
||||
const api = loadModule();
|
||||
const events = {
|
||||
completed: [],
|
||||
logs: [],
|
||||
messages: [],
|
||||
submittedPayloads: [],
|
||||
updatedTabs: [],
|
||||
};
|
||||
const stateQueue = [...pageStates];
|
||||
const submitQueue = [...submitResults];
|
||||
const urlQueue = [...tabUrls];
|
||||
let lastUrl = urlQueue.shift() || 'https://www.paypal.com/signin';
|
||||
|
||||
const executor = api.createPayPalApproveExecutor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async (tabId = 1) => {
|
||||
if (urlQueue.length) {
|
||||
lastUrl = urlQueue.shift();
|
||||
}
|
||||
return {
|
||||
id: tabId,
|
||||
status: 'complete',
|
||||
url: lastUrl,
|
||||
};
|
||||
},
|
||||
query: async () => queryTabs,
|
||||
update: async (tabId, updateInfo) => {
|
||||
events.updatedTabs.push({ tabId, updateInfo });
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
events.completed.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTabUntilStopped: async () => {},
|
||||
getTabId,
|
||||
isTabAlive,
|
||||
sendTabMessageUntilStopped: async (_tabId, _source, message) => {
|
||||
events.messages.push(message.type);
|
||||
if (message.type === 'PAYPAL_GET_STATE') {
|
||||
return stateQueue.shift() || pageStates[pageStates.length - 1] || {};
|
||||
}
|
||||
if (message.type === 'PAYPAL_SUBMIT_LOGIN') {
|
||||
events.submittedPayloads.push(message.payload);
|
||||
return submitQueue.shift() || { submitted: true, phase: 'password_submitted' };
|
||||
}
|
||||
if (message.type === 'PAYPAL_DISMISS_PROMPTS') {
|
||||
return { clicked: 0 };
|
||||
}
|
||||
if (message.type === 'PAYPAL_CLICK_APPROVE') {
|
||||
return { clicked: true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
setState: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
waitForTabCompleteUntilStopped: async () => {},
|
||||
waitForTabUrlMatchUntilStopped: async () => {},
|
||||
});
|
||||
|
||||
return { executor, events };
|
||||
}
|
||||
|
||||
test('PayPal approve keeps original combined email and password login path', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' },
|
||||
{ needsLogin: false, approveReady: true },
|
||||
{ needsLogin: false, approveReady: true },
|
||||
],
|
||||
submitResults: [
|
||||
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
|
||||
],
|
||||
});
|
||||
|
||||
await executor.executePayPalApprove({
|
||||
paypalEmail: 'user@example.com',
|
||||
paypalPassword: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(events.submittedPayloads.length, 1);
|
||||
assert.deepEqual(events.completed.map((item) => item.step), [8]);
|
||||
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
|
||||
});
|
||||
|
||||
test('PayPal approve discovers an already open unregistered PayPal tab', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
{ needsLogin: false, approveReady: true },
|
||||
],
|
||||
submitResults: [],
|
||||
getTabId: async () => null,
|
||||
isTabAlive: async () => false,
|
||||
queryTabs: [
|
||||
{
|
||||
id: 7,
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
url: 'https://www.paypal.com/pay/?token=BA-demo',
|
||||
},
|
||||
],
|
||||
tabUrls: [
|
||||
'https://www.paypal.com/pay/?token=BA-demo',
|
||||
],
|
||||
});
|
||||
|
||||
await executor.executePayPalApprove({
|
||||
paypalEmail: 'user@example.com',
|
||||
paypalPassword: 'secret',
|
||||
});
|
||||
|
||||
assert.deepEqual(events.updatedTabs, [{ tabId: 7, updateInfo: { active: true } }]);
|
||||
assert.equal(events.logs.some(({ message }) => /发现 PayPal 页面/.test(message)), true);
|
||||
assert.deepEqual(events.completed.map((item) => item.step), [8]);
|
||||
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
|
||||
});
|
||||
|
||||
test('PayPal approve auto-detects split email then password pages', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: false, loginPhase: 'email' },
|
||||
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
|
||||
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
|
||||
{ needsLogin: false, approveReady: true },
|
||||
{ needsLogin: false, approveReady: true },
|
||||
],
|
||||
submitResults: [
|
||||
{ submitted: false, phase: 'email_submitted', awaiting: 'password_page' },
|
||||
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
|
||||
],
|
||||
});
|
||||
|
||||
await executor.executePayPalApprove({
|
||||
paypalEmail: 'user@example.com',
|
||||
paypalPassword: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(events.submittedPayloads.length, 2);
|
||||
assert.deepEqual(events.completed.map((item) => item.step), [8]);
|
||||
assert.equal(events.logs.some(({ message }) => /识别到密码页/.test(message)), true);
|
||||
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
|
||||
});
|
||||
|
||||
test('PayPal approve finishes when login redirects away from PayPal', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
{ needsLogin: true, hasEmailInput: false, hasPasswordInput: true, loginPhase: 'password' },
|
||||
],
|
||||
submitResults: [
|
||||
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
|
||||
],
|
||||
tabUrls: [
|
||||
'https://www.paypal.com/signin',
|
||||
'https://www.paypal.com/signin',
|
||||
'https://checkout.openai.com/return',
|
||||
],
|
||||
});
|
||||
|
||||
await executor.executePayPalApprove({
|
||||
paypalEmail: 'user@example.com',
|
||||
paypalPassword: 'secret',
|
||||
});
|
||||
|
||||
assert.equal(events.submittedPayloads.length, 1);
|
||||
assert.deepEqual(events.completed.map((item) => item.step), [8]);
|
||||
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), false);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/paypal-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 char = source[index];
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (char === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (char === '{' && signatureEnded) {
|
||||
braceStart = index;
|
||||
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 char = source[end];
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createElement({
|
||||
tag = 'div',
|
||||
type = '',
|
||||
id = '',
|
||||
name = '',
|
||||
text = '',
|
||||
value = '',
|
||||
placeholder = '',
|
||||
attrs = {},
|
||||
style = {},
|
||||
rect = { width: 160, height: 40 },
|
||||
parentElement = null,
|
||||
} = {}) {
|
||||
return {
|
||||
nodeType: 1,
|
||||
tag,
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
textContent: text,
|
||||
value,
|
||||
placeholder,
|
||||
disabled: false,
|
||||
hidden: Boolean(attrs.hidden),
|
||||
style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
...style,
|
||||
},
|
||||
parentElement,
|
||||
getAttribute(key) {
|
||||
if (key === 'type') return type;
|
||||
if (key === 'id') return id;
|
||||
if (key === 'name') return name;
|
||||
if (key === 'placeholder') return placeholder;
|
||||
if (key === 'value') return value;
|
||||
return Object.prototype.hasOwnProperty.call(attrs, key) ? attrs[key] : null;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return rect;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function loadApi(elements) {
|
||||
const document = {
|
||||
documentElement: {},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input') {
|
||||
return elements.filter((el) => el.tag === 'input');
|
||||
}
|
||||
if (selector === 'input[type="email"]') {
|
||||
return elements.filter((el) => el.tag === 'input' && el.type === 'email');
|
||||
}
|
||||
if (selector === 'input[type="password"]') {
|
||||
return elements.filter((el) => el.tag === 'input' && el.type === 'password');
|
||||
}
|
||||
if (selector.includes('button') || selector.includes('[role="button"]')) {
|
||||
return elements.filter((el) => el.tag === 'button' || el.attrs?.role === 'button');
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
getComputedStyle(el) {
|
||||
return el?.style || { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
},
|
||||
};
|
||||
|
||||
return new Function('document', 'window', `
|
||||
${extractFunction('isVisibleElement')}
|
||||
${extractFunction('normalizeText')}
|
||||
${extractFunction('getActionText')}
|
||||
${extractFunction('getVisibleControls')}
|
||||
${extractFunction('isEnabledControl')}
|
||||
${extractFunction('findClickableByText')}
|
||||
${extractFunction('findInputByPatterns')}
|
||||
${extractFunction('findEmailInput')}
|
||||
${extractFunction('findPasswordInput')}
|
||||
${extractFunction('findLoginNextButton')}
|
||||
${extractFunction('findEmailNextButton')}
|
||||
${extractFunction('findPasswordLoginButton')}
|
||||
${extractFunction('getPayPalLoginPhase')}
|
||||
return {
|
||||
findEmailInput,
|
||||
findPasswordInput,
|
||||
findEmailNextButton,
|
||||
findPasswordLoginButton,
|
||||
getPayPalLoginPhase,
|
||||
};
|
||||
`)(document, window);
|
||||
}
|
||||
|
||||
test('PayPal email page ignores hidden pre-rendered password input', () => {
|
||||
const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } });
|
||||
const emailInput = createElement({
|
||||
tag: 'input',
|
||||
type: 'text',
|
||||
id: 'login_email',
|
||||
name: 'login_email',
|
||||
value: 'user@example.com',
|
||||
placeholder: 'Email',
|
||||
});
|
||||
const hiddenPasswordInput = createElement({
|
||||
tag: 'input',
|
||||
type: 'password',
|
||||
id: 'login_password',
|
||||
name: 'login_password',
|
||||
parentElement: hiddenPanel,
|
||||
});
|
||||
const nextButton = createElement({
|
||||
tag: 'button',
|
||||
id: 'btnNext',
|
||||
text: 'Next',
|
||||
});
|
||||
|
||||
const api = loadApi([emailInput, hiddenPasswordInput, nextButton]);
|
||||
|
||||
assert.equal(api.findEmailInput(), emailInput);
|
||||
assert.equal(api.findPasswordInput(), null);
|
||||
assert.equal(api.findEmailNextButton(), nextButton);
|
||||
assert.equal(api.findPasswordLoginButton(), null);
|
||||
assert.equal(api.getPayPalLoginPhase(emailInput, api.findPasswordInput()), 'email');
|
||||
});
|
||||
|
||||
test('PayPal combined login page still sees visible password input', () => {
|
||||
const emailInput = createElement({
|
||||
tag: 'input',
|
||||
type: 'text',
|
||||
id: 'login_email',
|
||||
name: 'login_email',
|
||||
});
|
||||
const passwordInput = createElement({
|
||||
tag: 'input',
|
||||
type: 'password',
|
||||
id: 'login_password',
|
||||
name: 'login_password',
|
||||
});
|
||||
const loginButton = createElement({
|
||||
tag: 'button',
|
||||
id: 'btnLogin',
|
||||
text: 'Log In',
|
||||
});
|
||||
|
||||
const api = loadApi([emailInput, passwordInput, loginButton]);
|
||||
|
||||
assert.equal(api.findEmailInput(), emailInput);
|
||||
assert.equal(api.findPasswordInput(), passwordInput);
|
||||
assert.equal(api.findPasswordLoginButton(), loginButton);
|
||||
assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined');
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/plus-checkout.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const plainStart = source.indexOf(`function ${name}(`);
|
||||
const asyncStart = source.indexOf(`async function ${name}(`);
|
||||
const start = asyncStart >= 0
|
||||
? asyncStart
|
||||
: plainStart;
|
||||
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 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 createInput({ id = '', name = '', placeholder = '', containerText = '' }) {
|
||||
const attrs = {
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
type: 'text',
|
||||
};
|
||||
const container = {
|
||||
textContent: containerText,
|
||||
};
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'text',
|
||||
value: '',
|
||||
textContent: '',
|
||||
getAttribute: (key) => attrs[key] || '',
|
||||
closest: (selector) => {
|
||||
if (selector === 'label') return null;
|
||||
if (String(selector || '').includes('[data-testid]')) return container;
|
||||
return null;
|
||||
},
|
||||
getBoundingClientRect: () => ({ width: 240, height: 40 }),
|
||||
};
|
||||
}
|
||||
|
||||
function createElement({ tagName = 'BUTTON', text = '', attrs = {}, className = '' }) {
|
||||
return {
|
||||
tagName,
|
||||
textContent: text,
|
||||
value: '',
|
||||
className,
|
||||
dataset: {},
|
||||
id: attrs.id || '',
|
||||
checked: false,
|
||||
getAttribute: (key) => attrs[key] || '',
|
||||
closest: () => null,
|
||||
getBoundingClientRect: () => ({ width: 180, height: 64 }),
|
||||
};
|
||||
}
|
||||
|
||||
test('findAddressSearchInput skips the name field when its container says billing address', async () => {
|
||||
const bundle = [
|
||||
'function throwIfStopped() {}',
|
||||
'function sleep() { return Promise.resolve(); }',
|
||||
extractFunction('waitUntil'),
|
||||
extractFunction('isVisibleElement'),
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getActionText'),
|
||||
extractFunction('getFieldText'),
|
||||
extractFunction('getVisibleControls'),
|
||||
extractFunction('getVisibleTextInputs'),
|
||||
extractFunction('findInputByFieldText'),
|
||||
extractFunction('getDirectFieldHintText'),
|
||||
extractFunction('isNonAddressSearchInput'),
|
||||
extractFunction('isLikelyAddressSearchInput'),
|
||||
extractFunction('findAddressSearchInput'),
|
||||
].join('\n');
|
||||
|
||||
const nameInput = createInput({
|
||||
name: 'name',
|
||||
placeholder: 'Name',
|
||||
containerText: 'Billing address',
|
||||
});
|
||||
const addressInput = createInput({
|
||||
name: 'addressLine1',
|
||||
placeholder: 'Address',
|
||||
containerText: 'Billing address',
|
||||
});
|
||||
const inputs = [nameInput, addressInput];
|
||||
const documentMock = {
|
||||
readyState: 'complete',
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector === 'input, textarea') return inputs;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const windowMock = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
};
|
||||
const cssMock = {
|
||||
escape: (value) => String(value),
|
||||
};
|
||||
|
||||
const api = new Function('window', 'document', 'CSS', `
|
||||
${bundle}
|
||||
return { findAddressSearchInput, isNonAddressSearchInput };
|
||||
`)(windowMock, documentMock, cssMock);
|
||||
|
||||
assert.equal(api.isNonAddressSearchInput(nameInput), true);
|
||||
assert.equal(await api.findAddressSearchInput(), addressInput);
|
||||
});
|
||||
|
||||
test('isPayPalPaymentMethodActive requires a selected PayPal control', () => {
|
||||
const bundle = [
|
||||
extractFunction('isVisibleElement'),
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getActionText'),
|
||||
extractFunction('getSearchText'),
|
||||
extractFunction('getFieldText'),
|
||||
extractFunction('getCombinedSearchText'),
|
||||
extractFunction('getVisibleControls'),
|
||||
extractFunction('getVisibleTextInputs'),
|
||||
extractFunction('isDocumentLevelContainer'),
|
||||
extractFunction('getPayPalSearchCandidates'),
|
||||
extractFunction('hasCreditCardFields'),
|
||||
extractFunction('hasSelectedPayPalControl'),
|
||||
extractFunction('isPayPalPaymentMethodActive'),
|
||||
].join('\n');
|
||||
|
||||
const paypalButton = createElement({
|
||||
text: 'PayPal',
|
||||
attrs: {
|
||||
id: 'paypal-tab',
|
||||
role: 'tab',
|
||||
'aria-selected': '',
|
||||
},
|
||||
});
|
||||
const elements = [paypalButton];
|
||||
const documentMock = {
|
||||
documentElement: {},
|
||||
body: {},
|
||||
querySelectorAll: (selector) => {
|
||||
if (selector === 'input, textarea') return [];
|
||||
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', `
|
||||
${bundle}
|
||||
return { isPayPalPaymentMethodActive };
|
||||
`)(windowMock, documentMock, cssMock);
|
||||
|
||||
assert.equal(api.isPayPalPaymentMethodActive(), false);
|
||||
paypalButton.getAttribute = (key) => (key === 'aria-selected' ? 'true' : (paypalButton.id && key === 'id' ? paypalButton.id : ''));
|
||||
assert.equal(api.isPayPalPaymentMethodActive(), true);
|
||||
});
|
||||
|
||||
test('selectRegionDropdown opens the state dropdown and clicks the matching option', async () => {
|
||||
const bundle = [
|
||||
'function throwIfStopped() {}',
|
||||
'function sleep() { return Promise.resolve(); }',
|
||||
extractFunction('isVisibleElement'),
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('getActionText'),
|
||||
extractFunction('getRegionCandidates'),
|
||||
extractFunction('matchesRegionOption'),
|
||||
extractFunction('getRegionDropdownValue'),
|
||||
extractFunction('getVisibleRegionOptions'),
|
||||
extractFunction('selectRegionDropdown'),
|
||||
].join('\n');
|
||||
|
||||
const state = { opened: false };
|
||||
const clicks = [];
|
||||
const stateDropdown = createElement({
|
||||
tagName: 'DIV',
|
||||
text: 'State New South Wales',
|
||||
attrs: {
|
||||
role: 'combobox',
|
||||
'aria-haspopup': 'listbox',
|
||||
},
|
||||
});
|
||||
const options = [
|
||||
createElement({ tagName: 'DIV', text: 'New South Wales', attrs: { role: 'option' } }),
|
||||
createElement({ tagName: 'DIV', text: 'Western Australia', attrs: { role: 'option' } }),
|
||||
];
|
||||
const documentMock = {
|
||||
querySelectorAll: (selector) => {
|
||||
if (!state.opened) return [];
|
||||
if (selector === '[role="listbox"] [role="option"]' || selector === '[role="option"]') return options;
|
||||
if (selector === 'li') return [];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const windowMock = {
|
||||
getComputedStyle: () => ({ display: 'block', visibility: 'visible' }),
|
||||
};
|
||||
|
||||
const api = new Function('window', 'document', 'Event', 'clicks', 'stateDropdown', 'state', `
|
||||
function simulateClick(el) {
|
||||
clicks.push(el);
|
||||
if (el === stateDropdown) state.opened = true;
|
||||
}
|
||||
${bundle}
|
||||
return { selectRegionDropdown };
|
||||
`)(windowMock, documentMock, Event, clicks, stateDropdown, state);
|
||||
|
||||
await api.selectRegionDropdown(stateDropdown, 'Western Australia');
|
||||
|
||||
assert.deepEqual(clicks, [stateDropdown, options[1]]);
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function loadPlusCheckoutBillingModule() {
|
||||
const source = fs.readFileSync('background/steps/fill-plus-checkout.js', 'utf8');
|
||||
const globalScope = {};
|
||||
return new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutBilling;`)(globalScope);
|
||||
}
|
||||
|
||||
function createAddressSeed() {
|
||||
return {
|
||||
countryCode: 'DE',
|
||||
query: 'Berlin Mitte',
|
||||
suggestionIndex: 1,
|
||||
fallback: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
region: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAuAddressSeed() {
|
||||
return {
|
||||
countryCode: 'AU',
|
||||
query: 'Sydney NSW',
|
||||
suggestionIndex: 1,
|
||||
fallback: {
|
||||
address1: 'George Street',
|
||||
city: 'Sydney',
|
||||
region: 'New South Wales',
|
||||
postalCode: '2000',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSuccessfulBillingResult() {
|
||||
return {
|
||||
countryText: 'Germany',
|
||||
structuredAddress: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createExecutorHarness({
|
||||
frames,
|
||||
stateByFrame,
|
||||
readyByFrame = {},
|
||||
fetchImpl = null,
|
||||
getAddressSeedForCountry = () => createAddressSeed(),
|
||||
}) {
|
||||
const api = loadPlusCheckoutBillingModule();
|
||||
const events = {
|
||||
completed: [],
|
||||
ensuredTabs: [],
|
||||
injectedAllFrames: false,
|
||||
logs: [],
|
||||
messages: [],
|
||||
states: [],
|
||||
waitedUrls: [],
|
||||
};
|
||||
const checkoutTab = {
|
||||
id: 42,
|
||||
url: 'https://chatgpt.com/checkout/openai_ie/cs_test',
|
||||
status: 'complete',
|
||||
};
|
||||
|
||||
const executor = api.createPlusCheckoutBillingExecutor({
|
||||
addLog: async (message, level = 'info') => events.logs.push({ message, level }),
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async (tabId) => (tabId === checkoutTab.id ? checkoutTab : null),
|
||||
query: async (queryInfo) => {
|
||||
if (queryInfo.active && queryInfo.currentWindow) {
|
||||
return [checkoutTab];
|
||||
}
|
||||
if (queryInfo.url === 'https://chatgpt.com/checkout/*') {
|
||||
return [checkoutTab];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
sendMessage: async (tabId, message, options = {}) => {
|
||||
const frameId = Number.isInteger(options.frameId) ? options.frameId : 0;
|
||||
events.messages.push({ tabId, message, frameId });
|
||||
const hasConfiguredState = Object.prototype.hasOwnProperty.call(stateByFrame, frameId);
|
||||
if (message.type === 'PING') {
|
||||
if (readyByFrame[frameId] === false) {
|
||||
throw new Error('No receiving end');
|
||||
}
|
||||
return { ok: true, source: 'plus-checkout' };
|
||||
}
|
||||
if (readyByFrame[frameId] === false && !hasConfiguredState) {
|
||||
throw new Error('No receiving end');
|
||||
}
|
||||
if (message.type === 'PLUS_CHECKOUT_GET_STATE') {
|
||||
return stateByFrame[frameId] || { hasPayPal: false, paypalCandidates: [] };
|
||||
}
|
||||
return createSuccessfulBillingResult();
|
||||
},
|
||||
},
|
||||
scripting: {
|
||||
executeScript: async (details) => {
|
||||
if (details.target?.allFrames) {
|
||||
events.injectedAllFrames = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
webNavigation: {
|
||||
getAllFrames: async () => frames,
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async (step, payload) => events.completed.push({ step, payload }),
|
||||
ensureContentScriptReadyOnTabUntilStopped: async (source, tabId) => events.ensuredTabs.push({ source, tabId }),
|
||||
fetch: fetchImpl,
|
||||
generateRandomName: () => ({ firstName: 'Ada', lastName: 'Lovelace' }),
|
||||
getAddressSeedForCountry,
|
||||
getTabId: async () => null,
|
||||
isTabAlive: async () => false,
|
||||
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' };
|
||||
},
|
||||
});
|
||||
|
||||
return { checkoutTab, events, executor };
|
||||
}
|
||||
|
||||
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' }],
|
||||
stateByFrame: {
|
||||
0: {
|
||||
hasPayPal: true,
|
||||
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
|
||||
billingFieldsVisible: true,
|
||||
hasSubscribeButton: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
assert.deepEqual(events.ensuredTabs[0], { source: 'plus-checkout', tabId: checkoutTab.id });
|
||||
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL' && entry.frameId === 0), true);
|
||||
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS' && entry.frameId === 0), true);
|
||||
assert.equal(events.messages.some((entry) => entry.message.type === 'PLUS_CHECKOUT_CLICK_SUBSCRIBE' && entry.frameId === 0), true);
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
assert.equal(events.states.some((updates) => updates.plusCheckoutTabId === checkoutTab.id), true);
|
||||
assert.equal(events.logs.some((entry) => /当前已在 Plus Checkout 页面/.test(entry.message)), true);
|
||||
});
|
||||
|
||||
test('Plus checkout billing sends the billing command to the iframe that contains PayPal', async () => {
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
|
||||
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
const selectMessage = 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(fillMessage.frameId, 8);
|
||||
assert.equal(subscribeMessage.frameId, 0);
|
||||
assert.equal(events.logs.some((entry) => /checkout iframe/.test(entry.message)), true);
|
||||
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: [
|
||||
{ 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: true,
|
||||
paypalCandidates: [{ tag: 'button', text: 'PayPal' }],
|
||||
hasSubscribeButton: true,
|
||||
},
|
||||
7: { hasPayPal: false, paypalCandidates: [] },
|
||||
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
|
||||
},
|
||||
readyByFrame: {
|
||||
0: false,
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
const selectMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL');
|
||||
assert.equal(selectMessage.frameId, 0);
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('Plus checkout billing uses the autocomplete iframe for address suggestions when Stripe splits it out', async () => {
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
{ frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
|
||||
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
|
||||
9: { hasPayPal: false, paypalCandidates: [] },
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY');
|
||||
const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION');
|
||||
const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS');
|
||||
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
|
||||
assert.equal(fillQueryMessage.frameId, 8);
|
||||
assert.equal(suggestionMessage.frameId, 9);
|
||||
assert.equal(ensureAddressMessage.frameId, 8);
|
||||
assert.equal(combinedFillMessage, undefined);
|
||||
assert.equal(events.logs.some((entry) => /Google 地址推荐/.test(entry.message)), true);
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('Plus checkout billing skips Google autocomplete when meiguodizhi returns a complete address', async () => {
|
||||
const fetchRequests = [];
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
{ frameId: 9, url: 'https://js.stripe.com/v3/elements-inner-autocompl.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
|
||||
8: { hasPayPal: false, paypalCandidates: [], billingFieldsVisible: true },
|
||||
9: { hasPayPal: false, paypalCandidates: [] },
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
fetchRequests.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
address: {
|
||||
Address: 'Rosa-Luxemburg-Strasse 40',
|
||||
City: 'Berlin',
|
||||
State: 'Berlin',
|
||||
Zip_Code: '69081',
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({});
|
||||
|
||||
const fillQueryMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY');
|
||||
const suggestionMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION');
|
||||
const ensureAddressMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS');
|
||||
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
|
||||
assert.equal(fillQueryMessage, undefined);
|
||||
assert.equal(suggestionMessage, undefined);
|
||||
assert.equal(ensureAddressMessage, undefined);
|
||||
assert.equal(combinedFillMessage.frameId, 8);
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.skipAutocomplete, true);
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.source, 'meiguodizhi');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.address1, 'Rosa-Luxemburg-Strasse 40');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.city, 'Berlin');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.postalCode, '69081');
|
||||
assert.equal(fetchRequests.length, 1);
|
||||
assert.equal(fetchRequests[0].url, 'https://www.meiguodizhi.com/api/v1/dz');
|
||||
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
|
||||
city: 'Berlin',
|
||||
path: '/de-address',
|
||||
method: 'refresh',
|
||||
});
|
||||
assert.equal(events.completed[0].step, 7);
|
||||
});
|
||||
|
||||
test('Plus checkout billing uses the detected checkout country before choosing an address seed', async () => {
|
||||
const requestedCountries = [];
|
||||
const fetchRequests = [];
|
||||
const { events, executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
{ frameId: 8, url: 'https://js.stripe.com/v3/elements-inner-address.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
7: { hasPayPal: true, paypalCandidates: [{ tag: 'button', text: 'PayPal' }] },
|
||||
8: {
|
||||
hasPayPal: false,
|
||||
paypalCandidates: [],
|
||||
billingFieldsVisible: true,
|
||||
countryText: 'Australia',
|
||||
},
|
||||
},
|
||||
getAddressSeedForCountry: (countryValue) => {
|
||||
requestedCountries.push(countryValue);
|
||||
return /australia|au/i.test(String(countryValue || '')) ? createAuAddressSeed() : createAddressSeed();
|
||||
},
|
||||
fetchImpl: async (url, init) => {
|
||||
fetchRequests.push({ url, init });
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
status: 'ok',
|
||||
address: {
|
||||
Address: '98 Ocean Street',
|
||||
City: 'Sydney South',
|
||||
State: 'New South Wales',
|
||||
Zip_Code: '2000',
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await executor.executePlusCheckoutBilling({ plusCheckoutCountry: 'DE' });
|
||||
|
||||
const combinedFillMessage = events.messages.find((entry) => entry.message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS');
|
||||
assert.equal(requestedCountries[0], 'Australia');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.countryCode, 'AU');
|
||||
assert.equal(combinedFillMessage.message.payload.addressSeed.fallback.region, 'New South Wales');
|
||||
assert.deepEqual(JSON.parse(fetchRequests[0].init.body), {
|
||||
city: 'Sydney',
|
||||
path: '/au-address',
|
||||
method: 'refresh',
|
||||
});
|
||||
});
|
||||
|
||||
test('Plus checkout billing reports when the payment iframe exists but cannot receive the content script', async () => {
|
||||
const { executor } = createExecutorHarness({
|
||||
frames: [
|
||||
{ frameId: 0, url: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
{ frameId: 7, url: 'https://js.stripe.com/v3/elements-inner-payment.html' },
|
||||
],
|
||||
stateByFrame: {
|
||||
0: { hasPayPal: false, paypalCandidates: [], hasSubscribeButton: true },
|
||||
},
|
||||
readyByFrame: {
|
||||
7: false,
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
executor.executePlusCheckoutBilling({}),
|
||||
/已定位到 PayPal 所在 iframe(frameId=7),但账单脚本无法注入该 iframe/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('background/steps/create-plus-checkout.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutCreate;`)(globalScope);
|
||||
|
||||
test('Plus checkout create does not wait 20 seconds after opening checkout page', async () => {
|
||||
const events = [];
|
||||
const executor = api.createPlusCheckoutCreateExecutor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.push({ type: 'log', message, level });
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async (tabId, payload) => {
|
||||
events.push({ type: 'tab-update', tabId, payload });
|
||||
},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
events.push({ type: 'complete', step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTabUntilStopped: async () => {
|
||||
events.push({ type: 'ready' });
|
||||
},
|
||||
reuseOrCreateTab: async () => 42,
|
||||
sendTabMessageUntilStopped: async () => ({
|
||||
checkoutUrl: 'https://checkout.stripe.com/c/pay/session',
|
||||
country: 'US',
|
||||
currency: 'USD',
|
||||
}),
|
||||
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();
|
||||
|
||||
const sleepEvents = events.filter((event) => event.type === 'sleep');
|
||||
assert.deepStrictEqual(sleepEvents.map((event) => event.ms), [1000, 1000]);
|
||||
|
||||
const completeIndex = events.findIndex((event) => event.type === 'complete');
|
||||
const readyLogIndex = events.findIndex((event) => event.type === 'log' && /Plus Checkout 页面已就绪/.test(event.message));
|
||||
assert.ok(readyLogIndex > -1);
|
||||
assert.ok(completeIndex > readyLogIndex);
|
||||
assert.equal(events.some((event) => event.type === 'sleep' && event.ms === 20000), false);
|
||||
assert.equal(events.some((event) => event.type === 'log' && /固定等待 20 秒后继续下一步/.test(event.message)), false);
|
||||
});
|
||||
@@ -48,12 +48,24 @@ function extractFunction(name) {
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
function createApi({ refreshImpl, confirmImpl, dismissImpl } = {}) {
|
||||
function createApi({
|
||||
refreshImpl,
|
||||
confirmImpl,
|
||||
dismissImpl,
|
||||
runCount = 3,
|
||||
plusModeEnabled = false,
|
||||
plusRiskEnabled = false,
|
||||
plusRiskConfirmed = true,
|
||||
plusRiskDismissPrompt = false,
|
||||
plusContributionImpl,
|
||||
} = {}) {
|
||||
const bundle = extractFunction('startAutoRunFromCurrentSettings');
|
||||
|
||||
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' };
|
||||
@@ -75,7 +87,7 @@ const console = {
|
||||
events.push({ type: 'warn', args });
|
||||
},
|
||||
};
|
||||
function getRunCountValue() { return 3; }
|
||||
function getRunCountValue() { return ${Math.max(1, Number(runCount) || 1)}; }
|
||||
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function shouldOfferAutoModeChoice() { return false; }
|
||||
async function openAutoStartChoiceDialog() { throw new Error('should not be called'); }
|
||||
@@ -85,6 +97,23 @@ 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' });
|
||||
@@ -155,3 +184,58 @@ test('startAutoRunFromCurrentSettings aborts when contribution update confirmati
|
||||
['refresh', 'confirm-check']
|
||||
);
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings shows Plus risk warning before starting more than 3 runs', async () => {
|
||||
const api = createApi({
|
||||
runCount: 4,
|
||||
plusModeEnabled: true,
|
||||
plusRiskEnabled: true,
|
||||
});
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
const events = api.getEvents();
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(
|
||||
events.map((entry) => entry.type),
|
||||
['refresh', 'confirm-check', 'plus-risk-modal', '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', 'confirm-check', 'plus-risk-modal']
|
||||
);
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings aborts when Plus contribution prompt opens contribution page', async () => {
|
||||
const api = createApi({
|
||||
plusModeEnabled: true,
|
||||
plusContributionImpl: `async (plusModeEnabled, events) => {
|
||||
events.push({ type: 'plus-contribution-modal', plusModeEnabled });
|
||||
return false;
|
||||
}`,
|
||||
});
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(
|
||||
api.getEvents().map((entry) => entry.type),
|
||||
['refresh', 'confirm-check', 'plus-contribution-modal']
|
||||
);
|
||||
assert.equal(api.getEvents()[2].plusModeEnabled, true);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,48 @@ 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');
|
||||
|
||||
|
||||
@@ -122,6 +122,59 @@ test('sidepanel html contains contribution mode runtime UI and loads the module
|
||||
assert.ok(moduleIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel settings refresh preserves rendered step progress', () => {
|
||||
const applySettingsStateSource = extractFunction('applySettingsState');
|
||||
assert.doesNotMatch(
|
||||
applySettingsStateSource,
|
||||
/syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{\s*render:\s*true\s*\}\)/
|
||||
);
|
||||
assert.match(applySettingsStateSource, /renderStepStatuses\(latestState\)/);
|
||||
|
||||
const bundle = [
|
||||
extractFunction('isDoneStatus'),
|
||||
extractFunction('getStepStatuses'),
|
||||
extractFunction('renderSingleStepStatus'),
|
||||
extractFunction('renderStepStatuses'),
|
||||
extractFunction('updateProgressCounter'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const STATUS_ICONS = {
|
||||
pending: '',
|
||||
running: '',
|
||||
completed: 'C',
|
||||
failed: 'F',
|
||||
stopped: 'S',
|
||||
manual_completed: 'M',
|
||||
skipped: 'K',
|
||||
};
|
||||
let latestState = { stepStatuses: { 1: 'completed', 2: 'running', 3: 'pending' } };
|
||||
let STEP_IDS = [1, 2, 3];
|
||||
let STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', 3: 'pending' };
|
||||
const rows = new Map(STEP_IDS.map((step) => [step, { className: 'step-row' }]));
|
||||
const statusEls = new Map(STEP_IDS.map((step) => [step, { textContent: '' }]));
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
const match = selector.match(/data-step="(\\d+)"/);
|
||||
const step = match ? Number(match[1]) : 0;
|
||||
return selector.includes('step-status') ? statusEls.get(step) : rows.get(step);
|
||||
},
|
||||
};
|
||||
const stepsProgress = { textContent: '' };
|
||||
${bundle}
|
||||
return { renderStepStatuses, rows, statusEls, stepsProgress };
|
||||
`)();
|
||||
|
||||
api.renderStepStatuses();
|
||||
|
||||
assert.equal(api.rows.get(1).className, 'step-row completed');
|
||||
assert.equal(api.rows.get(2).className, 'step-row running');
|
||||
assert.equal(api.rows.get(3).className, 'step-row pending');
|
||||
assert.equal(api.statusEls.get(1).textContent, 'C');
|
||||
assert.equal(api.statusEls.get(2).textContent, '');
|
||||
assert.equal(api.stepsProgress.textContent, '1 / 3');
|
||||
});
|
||||
|
||||
test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => {
|
||||
const bundle = extractFunction('collectSettingsPayload');
|
||||
|
||||
@@ -195,6 +248,7 @@ return {
|
||||
`)(normalizeIcloudTargetMailboxType, normalizeIcloudForwardMailProvider);
|
||||
|
||||
const contributionPayload = api.collectSettingsPayload();
|
||||
assert.equal('panelMode' in contributionPayload, false);
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
@@ -203,6 +257,7 @@ return {
|
||||
|
||||
api.setLatestState({ contributionMode: false });
|
||||
const normalPayload = api.collectSettingsPayload();
|
||||
assert.equal(normalPayload.panelMode, 'cpa');
|
||||
assert.equal(normalPayload.customPassword, 'Secret123!');
|
||||
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
@@ -235,6 +290,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
@@ -262,6 +319,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
btnOpenAccountRecords: createElement(),
|
||||
btnOpenContributionUpload: createElement(),
|
||||
btnStartContribution: createElement(),
|
||||
contributionModeBadge: createElement(),
|
||||
inputContributionNickname: createElement({ value: '贡献者昵称' }),
|
||||
inputContributionQq: createElement({ value: '123456' }),
|
||||
contributionCallbackStatus: createElement(),
|
||||
@@ -357,7 +415,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
state: message.payload.enabled
|
||||
? {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -372,6 +432,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
: {
|
||||
contributionMode: false,
|
||||
panelMode: 'cpa',
|
||||
contributionSource: 'cpa',
|
||||
contributionTargetGroupName: '',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
@@ -391,7 +453,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
state: {
|
||||
...latestState,
|
||||
contributionStatus: 'processing',
|
||||
contributionStatusMessage: '已提交回调,等待 CPA 确认',
|
||||
contributionStatusMessage: '已提交回调,等待服务端确认',
|
||||
contributionCallbackStatus: 'submitted',
|
||||
contributionCallbackMessage: '已提交回调',
|
||||
},
|
||||
@@ -418,13 +480,15 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.disabled, false);
|
||||
assert.equal(dom.contributionModeBadge.textContent, '');
|
||||
|
||||
manager.bindEvents();
|
||||
await dom.btnContributionMode.listeners.click();
|
||||
|
||||
assert.equal(dom.contributionModePanel.hidden, false);
|
||||
assert.equal(dom.selectPanelMode.value, 'cpa');
|
||||
assert.equal(dom.selectPanelMode.value, 'sub2api');
|
||||
assert.equal(dom.selectPanelMode.disabled, true);
|
||||
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
|
||||
assert.equal(dom.btnOpenAccountRecords.disabled, true);
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
|
||||
@@ -457,7 +521,7 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
assert.equal(statusState.contributionStatus, 'processing');
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85\u670d\u52a1\u7aef\u786e\u8ba4');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io', 'https://apikey.qzz.io/upload']);
|
||||
@@ -480,7 +544,9 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
blocked = true;
|
||||
latestState = {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
panelMode: 'sub2api',
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionNickname: '贡献者昵称',
|
||||
contributionQq: '123456',
|
||||
contributionSessionId: 'session-002',
|
||||
@@ -491,6 +557,8 @@ test('contribution mode manager enters mode, starts main auto flow, polls contri
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
manager.render();
|
||||
assert.equal(dom.selectPanelMode.value, 'sub2api');
|
||||
assert.equal(dom.contributionModeBadge.textContent, 'SUB2API');
|
||||
assert.equal(dom.btnExitContributionMode.disabled, true);
|
||||
manager.stopPolling();
|
||||
});
|
||||
|
||||
@@ -216,3 +216,43 @@ return {
|
||||
assert.equal(modalPayload.actions[1].id, 'add_phone');
|
||||
assert.equal(modalPayload.actions[1].label, '出现手机号验证');
|
||||
});
|
||||
|
||||
test('sidepanel custom verification dialog exposes add-phone action for Plus login code step', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getCustomVerificationPromptCopy'),
|
||||
extractFunction('openCustomVerificationConfirmDialog'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let openActionModalPayload = null;
|
||||
|
||||
async function openActionModal(options) {
|
||||
openActionModalPayload = options;
|
||||
return options.buildResult('add_phone');
|
||||
}
|
||||
|
||||
async function openConfirmModal() {
|
||||
throw new Error('Plus login code step should use action modal');
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
getCustomVerificationPromptCopy,
|
||||
openCustomVerificationConfirmDialog,
|
||||
getOpenActionModalPayload: () => openActionModalPayload,
|
||||
};
|
||||
`)();
|
||||
|
||||
const prompt = api.getCustomVerificationPromptCopy(11);
|
||||
assert.equal(prompt.phoneActionLabel, '出现手机号验证');
|
||||
|
||||
const result = await api.openCustomVerificationConfirmDialog(11);
|
||||
assert.deepEqual(result, {
|
||||
confirmed: false,
|
||||
addPhoneDetected: true,
|
||||
});
|
||||
|
||||
const modalPayload = api.getOpenActionModalPayload();
|
||||
assert.equal(modalPayload.actions[1].id, 'add_phone');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => source.indexOf(marker))
|
||||
.find((index) => index >= 0);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing function ${name}`);
|
||||
}
|
||||
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let braceStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (braceStart < 0) {
|
||||
throw new Error(`missing body for function ${name}`);
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < source.length; end += 1) {
|
||||
const ch = source[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function extractBundle(names) {
|
||||
return names.map((name) => extractFunction(name)).join('\n');
|
||||
}
|
||||
|
||||
function createPlusRecords(count, contributionMode = false) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
recordId: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`,
|
||||
email: `${contributionMode ? 'contribution' : 'plus'}-${index}@example.com`,
|
||||
finalStatus: 'success',
|
||||
plusModeEnabled: true,
|
||||
contributionMode,
|
||||
}));
|
||||
}
|
||||
|
||||
test('Plus contribution prompt starts every five normal Plus successes', () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
${bundle}
|
||||
return {
|
||||
getPlusContributionPromptProgress,
|
||||
shouldShowPlusContributionPrompt,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(4), true, { promptBaseline: 0, donationCredit: 0 }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(5), true, { promptBaseline: 0, donationCredit: 0 }),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(createPlusRecords(8), false, { promptBaseline: 0, donationCredit: 0 }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test('Plus contribution success and donated credit delay the next prompt', () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
${bundle}
|
||||
return { shouldShowPlusContributionPrompt };
|
||||
`)();
|
||||
|
||||
const afterPromptLedger = { promptBaseline: 5, donationCredit: 0 };
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(
|
||||
[...createPlusRecords(14), ...createPlusRecords(1, true)],
|
||||
true,
|
||||
afterPromptLedger
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
api.shouldShowPlusContributionPrompt(
|
||||
[...createPlusRecords(15), ...createPlusRecords(1, true)],
|
||||
true,
|
||||
afterPromptLedger
|
||||
),
|
||||
true
|
||||
);
|
||||
|
||||
const donatedLedger = { promptBaseline: 5, donationCredit: 20 };
|
||||
assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(29), true, donatedLedger), false);
|
||||
assert.equal(api.shouldShowPlusContributionPrompt(createPlusRecords(30), true, donatedLedger), true);
|
||||
});
|
||||
|
||||
test('Plus contribution support modal includes WeChat image and expected actions', async () => {
|
||||
const bundle = extractBundle([
|
||||
'getPlusContributionSupportImageUrl',
|
||||
'buildPlusContributionSupportPromptHtml',
|
||||
'openPlusContributionSupportModal',
|
||||
]);
|
||||
|
||||
const api = new Function(`
|
||||
let capturedOptions = null;
|
||||
const chrome = { runtime: { getURL: (path) => 'chrome-extension://test/' + path } };
|
||||
function escapeHtml(value) { return String(value || ''); }
|
||||
async function openActionModal(options) {
|
||||
capturedOptions = options;
|
||||
return 'donated';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
openPlusContributionSupportModal,
|
||||
getCapturedOptions() {
|
||||
return capturedOptions;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const choice = await api.openPlusContributionSupportModal();
|
||||
const options = api.getCapturedOptions();
|
||||
|
||||
assert.equal(choice, 'donated');
|
||||
assert.equal(options.title, 'Plus 功能使用反馈');
|
||||
assert.match(options.messageHtml, /docs\/images\/微信\.png/);
|
||||
assert.deepEqual(options.actions.map((action) => action.label), ['取消', '去贡献账号', '已打赏']);
|
||||
});
|
||||
|
||||
test('Plus contribution prompt marks shown and donated choice adds twenty credits', async () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'getPlusContributionPromptLedger',
|
||||
'setPlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
'markPlusContributionPromptShown',
|
||||
'addPlusContributionPromptCredit',
|
||||
'maybeShowPlusContributionPromptBeforeAutoRun',
|
||||
]);
|
||||
|
||||
const api = new Function('records', `
|
||||
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
|
||||
const latestState = { accountRunHistory: records };
|
||||
const storage = {};
|
||||
const events = [];
|
||||
const localStorage = {
|
||||
getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; },
|
||||
setItem(key, value) { storage[key] = String(value); },
|
||||
};
|
||||
async function openPlusContributionSupportModal() {
|
||||
events.push({ type: 'modal' });
|
||||
return 'donated';
|
||||
}
|
||||
function showToast(message, type) {
|
||||
events.push({ type: 'toast', message, toastType: type });
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
events.push({ type: 'open', url });
|
||||
}
|
||||
function getContributionPortalUrl() {
|
||||
return 'https://apikey.qzz.io';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowPlusContributionPromptBeforeAutoRun,
|
||||
getLedger() {
|
||||
return JSON.parse(storage[PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY] || '{}');
|
||||
},
|
||||
getEvents() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)(createPlusRecords(5));
|
||||
|
||||
const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'toast']);
|
||||
assert.deepEqual(api.getLedger(), {
|
||||
promptBaseline: 5,
|
||||
donationCredit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
test('Plus contribution prompt opens portal and aborts normal auto run when contribute is chosen', async () => {
|
||||
const bundle = extractBundle([
|
||||
'normalizePlusContributionPromptNumber',
|
||||
'normalizePlusContributionPromptLedger',
|
||||
'getPlusContributionPromptLedger',
|
||||
'setPlusContributionPromptLedger',
|
||||
'isSuccessfulPlusAccountRecord',
|
||||
'getPlusContributionPromptTotals',
|
||||
'getPlusContributionPromptProgress',
|
||||
'shouldShowPlusContributionPrompt',
|
||||
'markPlusContributionPromptShown',
|
||||
'addPlusContributionPromptCredit',
|
||||
'maybeShowPlusContributionPromptBeforeAutoRun',
|
||||
]);
|
||||
|
||||
const api = new Function('records', `
|
||||
const PLUS_CONTRIBUTION_PROMPT_LEDGER_STORAGE_KEY = 'multipage-plus-contribution-prompt-ledger';
|
||||
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
|
||||
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
|
||||
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
|
||||
const latestState = { accountRunHistory: records };
|
||||
const storage = {};
|
||||
const events = [];
|
||||
const localStorage = {
|
||||
getItem(key) { return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null; },
|
||||
setItem(key, value) { storage[key] = String(value); },
|
||||
};
|
||||
async function openPlusContributionSupportModal() {
|
||||
events.push({ type: 'modal' });
|
||||
return 'contribute';
|
||||
}
|
||||
function showToast(message, type) {
|
||||
events.push({ type: 'toast', message, toastType: type });
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
events.push({ type: 'open', url });
|
||||
}
|
||||
function getContributionPortalUrl() {
|
||||
return 'https://apikey.qzz.io';
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowPlusContributionPromptBeforeAutoRun,
|
||||
getEvents() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)(createPlusRecords(5));
|
||||
|
||||
const result = await api.maybeShowPlusContributionPromptBeforeAutoRun(true);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(api.getEvents().map((event) => event.type), ['modal', 'open', 'toast']);
|
||||
assert.equal(api.getEvents()[1].url, 'https://apikey.qzz.io');
|
||||
});
|
||||
@@ -2,15 +2,16 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
test('step definitions module exposes ordered shared step metadata', () => {
|
||||
test('step definitions module exposes ordered normal and Plus step metadata', () => {
|
||||
const source = fs.readFileSync('data/step-definitions.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
const api = new Function('self', `${source}; return self.MultiPageStepDefinitions;`)(globalScope);
|
||||
const steps = api.getSteps();
|
||||
const plusSteps = api.getSteps({ plusModeEnabled: true });
|
||||
|
||||
assert.equal(Array.isArray(steps), true);
|
||||
assert.equal(steps.length >= 10, true);
|
||||
assert.equal(steps.length, 10);
|
||||
assert.deepStrictEqual(
|
||||
steps.map((step) => step.order),
|
||||
steps.map((step) => step.order).slice().sort((left, right) => left - right)
|
||||
@@ -30,6 +31,28 @@ test('step definitions module exposes ordered shared step metadata', () => {
|
||||
'platform-verify',
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
plusSteps.map((step) => step.key),
|
||||
[
|
||||
'open-chatgpt',
|
||||
'submit-signup-email',
|
||||
'fill-password',
|
||||
'fetch-signup-code',
|
||||
'fill-profile',
|
||||
'plus-checkout-create',
|
||||
'plus-checkout-billing',
|
||||
'paypal-approve',
|
||||
'plus-checkout-return',
|
||||
'oauth-login',
|
||||
'fetch-login-code',
|
||||
'confirm-oauth',
|
||||
'platform-verify',
|
||||
]
|
||||
);
|
||||
assert.equal(plusSteps.some((step) => step.key === 'clear-login-cookies'), false);
|
||||
assert.equal(plusSteps.some((step) => step.key === 'fetch-login-code'), true);
|
||||
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);
|
||||
});
|
||||
|
||||
test('sidepanel html loads shared step definitions before sidepanel bootstrap', () => {
|
||||
@@ -41,3 +64,10 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(definitionsIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel html exposes Plus mode and PayPal settings', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="input-plus-mode-enabled"/);
|
||||
assert.match(html, /id="input-paypal-email"/);
|
||||
assert.match(html, /id="input-paypal-password"/);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,10 @@ function findOneTimeCodeLoginTrigger() {
|
||||
return ${JSON.stringify(overrides.switchTrigger || null)};
|
||||
}
|
||||
|
||||
function findLoginEntryTrigger() {
|
||||
return ${JSON.stringify(overrides.loginEntryTrigger || null)};
|
||||
}
|
||||
|
||||
function getLoginSubmitButton() {
|
||||
return ${JSON.stringify(overrides.submitButton || null)};
|
||||
}
|
||||
@@ -215,17 +219,29 @@ return {
|
||||
consentReady: true,
|
||||
});
|
||||
|
||||
const inspected = api.inspectLoginAuthState();
|
||||
assert.strictEqual(inspected.state, 'oauth_consent_page');
|
||||
|
||||
const snapshot = api.normalizeStep6Snapshot({
|
||||
state: 'oauth_consent_page',
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
});
|
||||
|
||||
assert.strictEqual(snapshot.state, 'unknown', '第六步应忽略 oauth_consent_page 状态');
|
||||
assert.strictEqual(snapshot.state, 'oauth_consent_page', '第六步应保留 oauth_consent_page 状态');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createApi({
|
||||
loginEntryTrigger: { id: 'continue-email' },
|
||||
});
|
||||
|
||||
const snapshot = api.inspectLoginAuthState();
|
||||
assert.strictEqual(snapshot.state, 'entry_page');
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"),
|
||||
'inspectLoginAuthState 不应再产出 oauth_consent_page 状态'
|
||||
extractFunction('inspectLoginAuthState').includes("state: 'oauth_consent_page'"),
|
||||
'inspectLoginAuthState 应产出 oauth_consent_page 状态'
|
||||
);
|
||||
|
||||
console.log('step6 login state tests passed');
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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 index = start; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (char === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (char === '{' && signatureEnded) {
|
||||
braceStart = index;
|
||||
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 char = source[end];
|
||||
if (char === '{') depth += 1;
|
||||
if (char === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('password submit treats direct OAuth consent as a login-code skip', async () => {
|
||||
const api = new Function(`
|
||||
const location = { href: 'https://auth.openai.com/authorize' };
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: 'oauth_consent_page',
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {
|
||||
throw new Error('should not wait once oauth consent is detected');
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6OAuthConsentSuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('getStep6OptionMessage')}
|
||||
${extractFunction('resolveStep6PostSubmitSnapshot')}
|
||||
${extractFunction('waitForStep6PostSubmitTransition')}
|
||||
${extractFunction('waitForStep6PasswordSubmitTransition')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return waitForStep6PasswordSubmitTransition(123, 1000);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const transition = await api.run();
|
||||
|
||||
assert.equal(transition.action, 'done');
|
||||
assert.equal(transition.result.state, 'oauth_consent_page');
|
||||
assert.equal(transition.result.skipLoginVerificationStep, true);
|
||||
assert.equal(transition.result.directOAuthConsentPage, true);
|
||||
assert.equal(transition.result.loginVerificationRequestedAt, null);
|
||||
});
|
||||
|
||||
test('step 7 entry succeeds when the auth page is already on OAuth consent', async () => {
|
||||
const logs = [];
|
||||
const api = new Function(`
|
||||
const location = { href: 'https://auth.openai.com/authorize' };
|
||||
const logs = arguments[0];
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: 'oauth_consent_page',
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6OAuthConsentSuccessResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('step6_login')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return step6_login({ email: 'user@example.com' });
|
||||
},
|
||||
};
|
||||
`)(logs);
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.equal(result.step6Outcome, 'success');
|
||||
assert.equal(result.state, 'oauth_consent_page');
|
||||
assert.equal(result.skipLoginVerificationStep, true);
|
||||
assert.equal(result.directOAuthConsentPage, true);
|
||||
assert.equal(logs.some(({ level }) => level === 'ok'), true);
|
||||
});
|
||||
@@ -177,6 +177,31 @@ test('SUB2API step 10 uses the same proxy for code exchange and account creation
|
||||
assert.equal(context.completed[0].step, 10);
|
||||
});
|
||||
|
||||
test('SUB2API panel accepts Plus platform verify step 13', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
await vm.runInContext(`
|
||||
handleStep(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',
|
||||
sub2apiGroupName: 'codex',
|
||||
sub2apiSessionId: 'session-1',
|
||||
sub2apiOAuthState: 'oauth-state',
|
||||
sub2apiGroupId: 5
|
||||
})
|
||||
`, context);
|
||||
|
||||
const exchangeCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/exchange-code');
|
||||
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
|
||||
|
||||
assert.equal(exchangeCall.body.code, 'callback-code');
|
||||
assert.equal(createCall.body.group_ids[0], 5);
|
||||
assert.equal(context.completed[0].step, 13);
|
||||
});
|
||||
|
||||
test('SUB2API step 1 omits proxy_id when default proxy is empty', async () => {
|
||||
const fetchCalls = [];
|
||||
const context = createSub2ApiPanelContext(fetchCalls);
|
||||
|
||||
@@ -833,6 +833,69 @@ test('verification flow uses configured login resend count for step 8', async ()
|
||||
assert.equal(pollCalls, 3);
|
||||
});
|
||||
|
||||
test('verification flow can complete Plus visible login-code step with shared step 8 semantics', async () => {
|
||||
const completed = [];
|
||||
const fillMessages = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completed.push({ step, payload });
|
||||
},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
fillMessages.push(message);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({ code: '654321', emailTimestamp: 456 }),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
8,
|
||||
{ email: 'user@example.com', lastLoginCode: null },
|
||||
{ provider: 'qq', label: 'QQ 邮箱' },
|
||||
{
|
||||
completionStep: 11,
|
||||
requestFreshCodeFirst: false,
|
||||
maxResendRequests: 0,
|
||||
resendIntervalMs: 0,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(fillMessages.map((message) => message.step), [8]);
|
||||
assert.deepStrictEqual(completed, [
|
||||
{
|
||||
step: 11,
|
||||
payload: {
|
||||
emailTimestamp: 456,
|
||||
code: '654321',
|
||||
phoneVerificationRequired: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow waits during resend cooldown instead of tight-looping', async () => {
|
||||
const sleepCalls = [];
|
||||
let pollCalls = 0;
|
||||
|
||||
Reference in New Issue
Block a user