fix: preserve phone identity on oauth-login completion

- Merge latest origin/dev into PR #245 for the current review baseline.\n- Sync Step 7 completion identity before oauth-login step-key handling returns.\n- Cover the real oauth-login stepKey route in the message-router regression test.
This commit is contained in:
QLHazyCoder
2026-05-15 03:10:34 +08:00
66 changed files with 6953 additions and 542 deletions
+106
View File
@@ -145,3 +145,109 @@ return {
assert.equal(snapshot.autoRunTotalRuns, 1);
assert.equal(snapshot.autoRunAttemptRun, 0);
});
test('launchAutoRunTimerPlan cancels an invalid scheduled start before restarting auto-run', async () => {
const api = new Function(`
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
const AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS = 'between_rounds';
const AUTO_RUN_TIMER_KIND_BEFORE_RETRY = 'before_retry';
const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
let autoRunTimerLaunching = false;
let autoRunActive = false;
let autoRunCurrentRun = 0;
let autoRunTotalRuns = 1;
let autoRunAttemptRun = 0;
let autoRunSessionId = 0;
const state = {
activeFlowId: 'site-a',
panelMode: 'cpa',
signupMethod: 'phone',
autoRunDelayEnabled: false,
autoRunTimerPlan: {
kind: AUTO_RUN_TIMER_KIND_SCHEDULED_START,
fireAt: Date.now() + 60_000,
totalRuns: 2,
autoRunSkipFailures: false,
autoRunSessionId: 0,
countdownTitle: '已计划自动运行',
countdownNote: '等待启动',
},
};
let startCalls = 0;
let clearStopCalls = 0;
let clearAlarmCalls = 0;
const broadcasts = [];
const logs = [];
async function getState() {
return { ...state };
}
function getPendingAutoRunTimerPlan() {
return state.autoRunTimerPlan;
}
async function clearAutoRunTimerAlarm() {
clearAlarmCalls += 1;
}
async function broadcastAutoRunStatus(phase, statusPayload, statePayload) {
broadcasts.push({ phase, statusPayload, statePayload });
}
async function addLog(message, level) {
logs.push({ message, level });
}
async function setAutoRunDelayEnabledState() {}
function serializeAutoRunRoundSummaries(totalRuns, summaries = []) {
return Array.isArray(summaries) ? summaries : [];
}
function clearStopRequest() {
clearStopCalls += 1;
}
function startAutoRunLoop() {
startCalls += 1;
}
function validateAutoRunStartState() {
return {
ok: false,
errors: [{ message: '当前 flow 不支持手机号注册。' }],
};
}
${helperBundle}
return {
launchAutoRunTimerPlan,
snapshot() {
return {
startCalls,
clearStopCalls,
clearAlarmCalls,
broadcasts,
logs,
autoRunCurrentRun,
autoRunTotalRuns,
autoRunAttemptRun,
};
},
};
`)();
const started = await api.launchAutoRunTimerPlan('alarm');
const snapshot = api.snapshot();
assert.equal(started, false);
assert.equal(snapshot.startCalls, 0);
assert.equal(snapshot.clearStopCalls, 0);
assert.equal(snapshot.clearAlarmCalls, 1);
assert.equal(snapshot.broadcasts.length, 1);
assert.equal(snapshot.broadcasts[0].phase, 'idle');
assert.match(snapshot.logs[0].message, /自动运行计划已取消:当前 flow 不支持手机号注册。/);
assert.equal(snapshot.logs[0].level, 'error');
assert.equal(snapshot.autoRunCurrentRun, 0);
assert.equal(snapshot.autoRunTotalRuns, 1);
assert.equal(snapshot.autoRunAttemptRun, 0);
});
@@ -73,6 +73,8 @@ const PERSISTED_SETTING_DEFAULTS = {
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; }
function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); }
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
@@ -163,6 +165,8 @@ const PERSISTED_SETTING_DEFAULTS = {
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
function normalizeSignupMethod(value = '') { return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email'; }
function resolveSignupMethod(state = {}) { return normalizeSignupMethod(state?.signupMethod); }
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
@@ -351,6 +351,65 @@ test('message router re-syncs contribution mode before AUTO_RUN when sidepanel p
]);
});
test('message router blocks AUTO_RUN and SCHEDULE_AUTO_RUN when shared auto-run validation fails', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const calls = [];
const router = api.createMessageRouter({
clearStopRequest: () => {},
getPendingAutoRunTimerPlan: () => null,
getState: async () => ({
activeFlowId: 'site-a',
panelMode: 'cpa',
signupMethod: 'phone',
stepStatuses: {},
}),
normalizeRunCount: (value) => Number(value) || 1,
scheduleAutoRun: async () => {
calls.push({ type: 'scheduleAutoRun' });
return { ok: true };
},
setState: async (updates) => {
calls.push({ type: 'setState', updates });
},
startAutoRunLoop: () => {
calls.push({ type: 'startAutoRunLoop' });
},
validateAutoRunStart: () => ({
ok: false,
errors: [{ message: '当前 flow 不支持手机号注册。' }],
}),
});
await assert.rejects(
() => router.handleMessage({
type: 'AUTO_RUN',
payload: {
totalRuns: 2,
autoRunSkipFailures: true,
mode: 'restart',
},
}),
/当前 flow 不支持手机号注册。/
);
await assert.rejects(
() => router.handleMessage({
type: 'SCHEDULE_AUTO_RUN',
payload: {
totalRuns: 2,
delayMinutes: 5,
autoRunSkipFailures: false,
},
}),
/当前 flow 不支持手机号注册。/
);
assert.deepStrictEqual(calls, []);
});
test('account run history snapshot sync is disabled in contribution mode', () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
@@ -0,0 +1,28 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background forwards mail rule metadata to Hotmail helper and shared picker paths', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(
source,
/requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\]/,
'Hotmail helper 请求体应转发 requiredKeywords'
);
assert.match(
source,
/codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/,
'Hotmail helper 请求体应转发 codePatterns'
);
assert.match(
source,
/pickVerificationMessageWithTimeFallback\(fetchResult\.messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/,
'Hotmail API 轮询应把 rule metadata 传给共享验证码筛选器'
);
assert.match(
source,
/pickVerificationMessageWithTimeFallback\(messages,\s*\{[\s\S]*requiredKeywords:\s*pollPayload\.requiredKeywords\s*\|\|\s*\[\],[\s\S]*codePatterns:\s*pollPayload\.codePatterns\s*\|\|\s*\[\]/,
'Cloudflare Temp Email 轮询也应复用同一套 rule metadata'
);
});
+3
View File
@@ -731,6 +731,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
'async function getPersistedAliasState() {',
' return {};',
'}',
'function buildStatePatchWithRuntimeState(_currentState, updates) {',
' return updates;',
'}',
'const chrome = {',
' storage: {',
' session: {',
@@ -0,0 +1,125 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports mail rule registry and OpenAI mail rules modules', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/mail-rule-registry\.js/);
assert.match(source, /flows\/openai\/mail-rules\.js/);
});
test('mail rule registry exposes canonical OpenAI verification poll payloads', () => {
const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8');
const openAiSource = fs.readFileSync('flows/openai/mail-rules.js', 'utf8');
const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({});
const openAiApi = new Function('self', `${openAiSource}; return self.MultiPageOpenAiMailRules;`)({});
const openAiMailRules = openAiApi.createOpenAiMailRules({
getHotmailVerificationRequestTimestamp: (step) => (step === 4 ? 123 : 456),
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
});
const registry = registryApi.createMailRuleRegistry({
defaultFlowId: 'openai',
flowBuilders: {
openai: openAiMailRules,
},
});
assert.deepEqual(
registry.buildVerificationPollPayload(
4,
{
activeFlowId: 'openai',
email: 'user@example.com',
mailProvider: '2925',
mail2925Mode: 'receive',
},
{ excludeCodes: ['111111'] }
),
{
flowId: 'openai',
ruleId: 'openai-signup-code',
step: 4,
artifactType: 'code',
codePatterns: [
{
source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})',
flags: 'i',
},
{
source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})',
flags: 'i',
},
{
source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})',
flags: 'i',
},
],
filterAfterTimestamp: 0,
requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码'],
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'],
targetEmail: 'user@example.com',
targetEmailHints: ['user@example.com', 'user=example.com'],
mail2925MatchTargetEmail: true,
maxAttempts: 15,
intervalMs: 15000,
excludeCodes: ['111111'],
}
);
assert.deepEqual(
registry.buildVerificationPollPayload(8, {
activeFlowId: 'openai',
email: 'user@example.com',
step8VerificationTargetEmail: 'login@example.com',
}),
{
flowId: 'openai',
ruleId: 'openai-login-code',
step: 8,
artifactType: 'code',
codePatterns: [
{
source: '(?:chatgpt\\s+log-?in\\s+code|enter\\s+this\\s+code)[^0-9]{0,24}(\\d{6})',
flags: 'i',
},
{
source: 'your\\s+chatgpt\\s+code\\s+is\\s+(\\d{6})',
flags: 'i',
},
{
source: '(?:verification\\s+code|temporary\\s+verification\\s+code|your\\s+chatgpt\\s+code|code(?:\\s+is)?)[^0-9]{0,16}(\\d{6})',
flags: 'i',
},
],
filterAfterTimestamp: 456,
requiredKeywords: ['openai', 'chatgpt', 'verify', 'verification', 'confirm', '验证码', '代码', 'login'],
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
targetEmail: 'login@example.com',
targetEmailHints: ['login@example.com', 'login=example.com'],
mail2925MatchTargetEmail: false,
maxAttempts: 5,
intervalMs: 3000,
}
);
});
test('mail rule registry rejects unknown active flow ids instead of silently using OpenAI rules', () => {
const registrySource = fs.readFileSync('background/mail-rule-registry.js', 'utf8');
const registryApi = new Function('self', `${registrySource}; return self.MultiPageBackgroundMailRuleRegistry;`)({});
const registry = registryApi.createMailRuleRegistry({
defaultFlowId: 'openai',
flowBuilders: {},
});
assert.throws(
() => registry.buildVerificationPollPayload(4, {
activeFlowId: 'site-a',
email: 'user@example.com',
}),
/未找到 flow=site-a 的邮件轮询规则构造器/
);
});
@@ -162,3 +162,103 @@ test('SAVE_SETTING broadcasts operation delay setting without background success
assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false });
assert.equal(logs.length, 0);
});
test('SAVE_SETTING re-resolves signup method when panel mode changes', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
let state = {
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: false,
panelMode: 'sub2api',
};
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'panelMode')
? { panelMode: input.panelMode }
: {},
broadcastDataUpdate: () => {},
getState: async () => ({ ...state }),
resolveSignupMethod: (nextState = {}) => nextState.panelMode === 'cpa' ? 'email' : 'phone',
setPersistentSettings: async () => {},
setState: async (updates) => {
state = { ...state, ...updates };
},
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
payload: { panelMode: 'cpa' },
});
assert.equal(response.ok, true);
assert.equal(state.panelMode, 'cpa');
assert.equal(state.signupMethod, 'email');
});
test('SAVE_SETTING applies shared mode-switch normalization before persisting incompatible capability flags', async () => {
const source = fs.readFileSync('background/message-router.js', 'utf8');
const globalScope = { console };
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
const persistedPayloads = [];
let state = {
activeFlowId: 'site-a',
signupMethod: 'email',
phoneVerificationEnabled: false,
plusModeEnabled: false,
panelMode: 'cpa',
};
const router = api.createMessageRouter({
addLog: async () => {},
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: (input = {}) => ({
plusModeEnabled: Boolean(input.plusModeEnabled),
phoneVerificationEnabled: Boolean(input.phoneVerificationEnabled),
signupMethod: String(input.signupMethod || 'email'),
}),
broadcastDataUpdate: () => {},
getState: async () => ({ ...state }),
resolveSignupMethod: (nextState = {}) => (
Boolean(nextState.phoneVerificationEnabled) && Boolean(nextState.plusModeEnabled) ? 'phone' : 'email'
),
setPersistentSettings: async (updates) => {
persistedPayloads.push({ ...updates });
},
setState: async (updates) => {
state = { ...state, ...updates };
},
validateModeSwitch: () => ({
ok: false,
errors: [{ code: 'plus_mode_unsupported', message: '当前 flow 不支持 Plus 模式。' }],
normalizedUpdates: {
plusModeEnabled: false,
phoneVerificationEnabled: false,
signupMethod: 'email',
},
}),
});
const response = await router.handleMessage({
type: 'SAVE_SETTING',
payload: {
plusModeEnabled: true,
phoneVerificationEnabled: true,
signupMethod: 'phone',
},
});
assert.equal(response.ok, true);
assert.equal(state.plusModeEnabled, false);
assert.equal(state.phoneVerificationEnabled, false);
assert.equal(state.signupMethod, 'email');
assert.deepEqual(persistedPayloads[0], {
plusModeEnabled: false,
phoneVerificationEnabled: false,
signupMethod: 'email',
});
assert.equal(response.modeValidation?.errors?.[0]?.code, 'plus_mode_unsupported');
});
@@ -251,6 +251,11 @@ test('message router persists phone signup identity from step 7 completion paylo
};
const { router, events } = createRouter({
state: { stepStatuses: { 7: 'completed', 8: 'pending' } },
getStepDefinitionForState: (step) => (
step === 7
? { key: 'oauth-login' }
: (step === 8 ? { key: 'fetch-login-code' } : null)
),
});
await router.handleStepData(7, {
@@ -29,6 +29,7 @@ test('navigation utils recognize signup password pages for email and phone signu
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/create-account/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in'), false);
assert.equal(utils.isSignupEntryHost('www.chatgpt.com'), true);
});
test('navigation utils treat 126 mail hosts as part of the shared NetEase mail family', () => {
+95 -24
View File
@@ -16,10 +16,24 @@ test('panel bridge module exposes a factory', () => {
assert.equal(typeof api?.createPanelBridge, 'function');
});
test('panel bridge requests oauth url with step 7 log label payload', () => {
function loadPanelBridgeWithSub2Api() {
const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8');
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
assert.match(source, /logStep:\s*7/);
assert.doesNotMatch(source, /logStep:\s*6/);
return new Function('self', `${sub2apiSource}\n${source}; return self.MultiPageBackgroundPanelBridge;`)({});
}
function createSub2ApiResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(payload),
};
}
test('panel bridge requests SUB2API oauth url via direct API', () => {
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
assert.match(source, /generateOpenAiAuthUrl/);
assert.doesNotMatch(source, /REQUEST_OAUTH_URL/);
});
test('panel bridge can request codex2api oauth url via protocol', async () => {
@@ -117,16 +131,63 @@ test('panel bridge can request cpa oauth url via management api', async () => {
}
});
test('panel bridge forwards SUB2API account priority when requesting oauth url', async () => {
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
const sentMessages = [];
test('panel bridge can request SUB2API oauth url without opening the admin page', async () => {
const originalFetch = globalThis.fetch;
const fetchCalls = [];
globalThis.fetch = async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
fetchCalls.push({ path: parsed.pathname, search: parsed.search, method: options.method || 'GET', body });
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
if (parsed.pathname === '/api/v1/auth/login') {
return createSub2ApiResponse({
code: 0,
data: { access_token: 'admin-token' },
});
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createSub2ApiResponse({
code: 0,
data: [{ id: 5, name: 'codex', platform: 'openai' }],
});
}
if (parsed.pathname === '/api/v1/admin/proxies/all') {
return createSub2ApiResponse({
code: 0,
data: [{
id: 7,
name: 'shadowrocket',
protocol: 'socks5',
host: '127.0.0.1',
port: 1080,
status: 'active',
}],
});
}
if (parsed.pathname === '/api/v1/admin/openai/generate-auth-url') {
return createSub2ApiResponse({
code: 0,
data: {
auth_url: 'https://auth.openai.com/authorize?state=oauth-state',
session_id: 'session-123',
state: 'oauth-state',
},
});
}
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
};
const tabCreates = [];
const sentMessages = [];
const api = loadPanelBridgeWithSub2Api();
const bridge = api.createPanelBridge({
addLog: async () => {},
chrome: {
tabs: {
create: async () => ({ id: 72 }),
create: async (payload) => {
tabCreates.push(payload);
return { id: 72 };
},
},
},
closeConflictingTabsForSource: async () => {},
@@ -137,11 +198,7 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url',
rememberSourceLastUrl: async () => {},
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return {
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
};
return {};
},
sendToContentScriptResilient: async () => ({}),
waitForTabUrlFamily: async () => ({ id: 72 }),
@@ -149,16 +206,30 @@ test('panel bridge forwards SUB2API account priority when requesting oauth url',
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
});
await bridge.requestOAuthUrlFromPanel({
panelMode: 'sub2api',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiAccountPriority: 3,
}, { logLabel: '步骤 7' });
try {
const result = await bridge.requestOAuthUrlFromPanel({
panelMode: 'sub2api',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiDefaultProxyName: 'shadowrocket',
}, { logLabel: '步骤 7' });
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 3);
const generateCall = fetchCalls.find((call) => call.path === '/api/v1/admin/openai/generate-auth-url');
assert.equal(tabCreates.length, 0);
assert.equal(sentMessages.length, 0);
assert.equal(generateCall.body.proxy_id, 7);
assert.deepStrictEqual(result, {
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
sub2apiGroupId: 5,
sub2apiGroupIds: [5],
sub2apiDraftName: result.sub2apiDraftName,
sub2apiProxyId: 7,
});
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -80,72 +80,14 @@ test('platform verify module supports codex2api protocol callback exchange', asy
}
});
test('platform verify retries transient SUB2API oauth/token exchange errors before failing', async () => {
test('platform verify retries transient SUB2API oauth/token exchange errors before succeeding', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const logs = [];
const attempts = [];
let callCount = 0;
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
isTabAlive: async () => true,
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 12,
sendToContentScript: async (_source, message) => {
attempts.push(message.type);
callCount += 1;
if (callCount === 1) {
return {
error: 'request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF',
};
}
return { ok: true };
},
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
});
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
sub2apiEmail: 'flow@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(callCount, 2);
assert.deepStrictEqual(attempts, ['EXECUTE_STEP', 'EXECUTE_STEP']);
assert.equal(
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
true
);
});
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const logs = [];
let callCount = 0;
let contentScriptCalled = false;
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
@@ -167,14 +109,22 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 12,
sendToContentScript: async () => {
callCount += 1;
if (callCount === 1) {
return {
error: 'token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }',
};
}
return { ok: true };
contentScriptCalled = true;
return {};
},
createSub2ApiApi: () => ({
submitOpenAiCallback: async () => {
attempts.push('direct-api');
callCount += 1;
if (callCount === 1) {
throw new Error('request failed: Post "https://auth.openai.com/oauth/token": unexpected EOF');
}
return {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'SUB2API 已创建账号 #11',
};
},
}),
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
@@ -191,6 +141,74 @@ test('platform verify retries transient SUB2API token_exchange_user_error before
});
assert.equal(callCount, 2);
assert.equal(contentScriptCalled, false);
assert.deepStrictEqual(attempts, ['direct-api', 'direct-api']);
assert.equal(
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
true
);
});
test('platform verify retries transient SUB2API token_exchange_user_error before succeeding', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const logs = [];
let callCount = 0;
let contentScriptCalled = false;
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
},
},
closeConflictingTabsForSource: async () => {},
completeStepFromBackground: async () => {},
ensureContentScriptReadyOnTab: async () => {},
getPanelMode: () => 'sub2api',
getTabId: async () => 12,
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
isTabAlive: async () => true,
normalizeCodex2ApiUrl: (value) => value,
normalizeSub2ApiUrl: () => 'https://sub2api.example.com/admin/accounts',
rememberSourceLastUrl: async () => {},
reuseOrCreateTab: async () => 12,
sendToContentScript: async () => {
contentScriptCalled = true;
return { ok: true };
},
createSub2ApiApi: () => ({
submitOpenAiCallback: async () => {
callCount += 1;
if (callCount === 1) {
throw new Error('token exchange failed: status 400, body: { "error": { "message": "Invalid request. Please try again later.", "type": "invalid_request_error", "param": null, "code": "token_exchange_user_error" } }');
}
return {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'SUB2API 已创建账号 #11',
};
},
}),
sendToContentScriptResilient: async () => ({}),
shouldBypassStep9ForLocalCpa: () => false,
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
});
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub2api.example.com/admin/accounts',
sub2apiEmail: 'flow@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-123',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(callCount, 2);
assert.equal(contentScriptCalled, false);
assert.equal(
logs.some((entry) => /临时网络波动/.test(entry.message) && entry.level === 'warn'),
true
+133 -48
View File
@@ -42,6 +42,20 @@ function createDeps(overrides = {}) {
return { deps, logs, completed, uiCalls };
}
function loadStep10WithSub2Api() {
const sub2apiSource = fs.readFileSync('background/sub2api-api.js', 'utf8');
const step10Source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
return new Function('self', `${sub2apiSource}\n${step10Source}; return self.MultiPageBackgroundStep10;`)({});
}
function createSub2ApiResponse(payload, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(payload),
};
}
test('platform verify module submits CPA callback via management API first', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const originalFetch = globalThis.fetch;
@@ -240,11 +254,109 @@ test('platform verify module rejects callback when cpa oauth state mismatches',
}
});
test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
test('platform verify module submits Plus visible step 13 to SUB2API via direct API', async () => {
const originalFetch = globalThis.fetch;
const fetchCalls = [];
const sentMessages = [];
globalThis.fetch = async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body });
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
if (parsed.pathname === '/api/v1/auth/login') {
return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } });
}
if (parsed.pathname === '/api/v1/admin/groups/all') {
return createSub2ApiResponse({ code: 0, data: [{ id: 5, name: 'codex', platform: 'openai' }] });
}
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
return createSub2ApiResponse({
code: 0,
data: {
access_token: 'openai-access',
refresh_token: 'openai-refresh',
email: 'flow@example.com',
},
});
}
if (parsed.pathname === '/api/v1/admin/accounts') {
return createSub2ApiResponse({ code: 0, data: { id: 11 } });
}
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
};
const api = loadStep10WithSub2Api();
const { deps, completed } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
isTabAlive: async () => true,
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return { ok: true };
},
});
const executor = api.createStep10Executor(deps);
try {
await executor.executeStep10({
panelMode: 'sub2api',
visibleStep: 13,
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiGroupName: 'codex',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
});
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(sentMessages.length, 0);
assert.equal(exchangeCall.body.code, 'callback-code');
assert.equal(exchangeCall.body.state, 'oauth-state');
assert.deepStrictEqual(createCall.body.group_ids, [5]);
assert.deepStrictEqual(completed, [{
step: 13,
payload: {
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
verifiedStatus: 'SUB2API 已创建账号 #11',
},
}]);
} finally {
globalThis.fetch = originalFetch;
}
});
test('platform verify module forwards SUB2API account priority to direct create API', async () => {
const originalFetch = globalThis.fetch;
const fetchCalls = [];
const sentMessages = [];
globalThis.fetch = async (url, options = {}) => {
const parsed = new URL(url);
const body = options.body ? JSON.parse(options.body) : null;
fetchCalls.push({ path: parsed.pathname, method: options.method || 'GET', body });
if (parsed.pathname === '/api/v1/auth/login') {
return createSub2ApiResponse({ code: 0, data: { access_token: 'admin-token' } });
}
if (parsed.pathname === '/api/v1/admin/openai/exchange-code') {
return createSub2ApiResponse({
code: 0,
data: {
access_token: 'openai-access',
refresh_token: 'openai-refresh',
email: 'flow@example.com',
},
});
}
if (parsed.pathname === '/api/v1/admin/accounts') {
return createSub2ApiResponse({ code: 0, data: { id: 11 } });
}
return createSub2ApiResponse({ code: 1, message: `unexpected path ${parsed.pathname}` }, 404);
};
const api = loadStep10WithSub2Api();
const { deps } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
@@ -256,50 +368,23 @@ test('platform verify module sends Plus visible step 13 to SUB2API panel', async
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'sub2api',
visibleStep: 13,
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
});
try {
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
sub2apiGroupId: 5,
sub2apiAccountPriority: 2,
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.step, 13);
});
test('platform verify module forwards SUB2API account priority to panel', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const sentMessages = [];
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
isTabAlive: async () => true,
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return { ok: true };
},
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'sub2api',
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
sub2apiAccountPriority: 2,
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.payload.sub2apiAccountPriority, 2);
const createCall = fetchCalls.find((call) => call.path === '/api/v1/admin/accounts');
assert.equal(sentMessages.length, 0);
assert.equal(createCall.body.priority, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
@@ -0,0 +1,163 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadRuntimeStateApi() {
const source = fs.readFileSync('background/runtime-state.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.MultiPageBackgroundRuntimeState;`)(globalScope);
}
test('background imports runtime-state module and wires state view helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/runtime-state\.js/);
assert.match(source, /createRuntimeStateHelpers/);
assert.match(source, /buildStateViewWithRuntimeState/);
assert.match(source, /buildStatePatchWithRuntimeState/);
assert.match(source, /runtimeState:/);
});
test('runtime-state module exposes a factory', () => {
const api = loadRuntimeStateApi();
assert.equal(typeof api?.createRuntimeStateHelpers, 'function');
});
test('runtime-state view derives canonical flow metadata from legacy step state', () => {
const api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: {
1: 'pending',
2: 'pending',
10: 'pending',
},
getStepDefinitionForState(step) {
return {
1: { id: 1, key: 'open-chatgpt' },
2: { id: 2, key: 'submit-signup-email' },
10: { id: 10, key: 'oauth-login' },
}[Number(step)] || null;
},
});
const view = helpers.buildStateView({
currentStep: 2,
stepStatuses: {
1: 'completed',
2: 'running',
},
oauthUrl: 'https://auth.example.com/start',
plusCheckoutTabId: 88,
currentPhoneActivation: {
activationId: 'active-1',
phoneNumber: '+447700900123',
},
tabRegistry: {
'signup-page': { tabId: 12 },
},
sourceLastUrls: {
'signup-page': 'https://auth.example.com/start',
},
flowStartTime: 12345,
});
assert.equal(view.activeFlowId, 'openai');
assert.equal(view.currentNodeId, 'submit-signup-email');
assert.deepStrictEqual(view.legacyStepCompat, {
currentStep: 2,
stepStatuses: {
1: 'completed',
2: 'running',
10: 'pending',
},
});
assert.deepStrictEqual(view.nodeStatuses, {
'open-chatgpt': 'completed',
'submit-signup-email': 'running',
'oauth-login': 'pending',
});
assert.equal(view.runtimeState.flowState.openai.auth.oauthUrl, 'https://auth.example.com/start');
assert.equal(view.runtimeState.flowState.openai.plus.plusCheckoutTabId, 88);
assert.deepStrictEqual(view.runtimeState.flowState.openai.phoneVerification.currentPhoneActivation, {
activationId: 'active-1',
phoneNumber: '+447700900123',
});
assert.deepStrictEqual(view.sharedState, {
tabRegistry: {
'signup-page': { tabId: 12 },
},
sourceLastUrls: {
'signup-page': 'https://auth.example.com/start',
},
flowStartTime: 12345,
});
});
test('runtime-state patch accepts nested flow updates while keeping legacy compatibility fields in sync', () => {
const api = loadRuntimeStateApi();
const helpers = api.createRuntimeStateHelpers({
DEFAULT_ACTIVE_FLOW_ID: 'openai',
defaultStepStatuses: {
1: 'pending',
2: 'pending',
10: 'pending',
},
getStepDefinitionForState(step) {
return {
1: { id: 1, key: 'open-chatgpt' },
2: { id: 2, key: 'submit-signup-email' },
10: { id: 10, key: 'oauth-login' },
}[Number(step)] || null;
},
});
const patch = helpers.buildSessionStatePatch({
currentStep: 1,
stepStatuses: {
1: 'running',
2: 'pending',
10: 'pending',
},
oauthUrl: 'https://old.example.com/start',
}, {
runtimeState: {
activeRunId: 'run-001',
flowState: {
openai: {
auth: {
oauthUrl: 'https://new.example.com/start',
},
plus: {
plusCheckoutTabId: 99,
},
},
},
legacyStepCompat: {
currentStep: 10,
stepStatuses: {
1: 'completed',
10: 'running',
},
},
},
});
assert.equal(patch.activeFlowId, 'openai');
assert.equal(patch.activeRunId, 'run-001');
assert.equal(patch.currentNodeId, 'oauth-login');
assert.equal(patch.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.plusCheckoutTabId, 99);
assert.equal(patch.currentStep, 10);
assert.deepStrictEqual(patch.stepStatuses, {
1: 'completed',
2: 'pending',
10: 'running',
});
assert.deepStrictEqual(patch.nodeStatuses, {
'open-chatgpt': 'completed',
'submit-signup-email': 'pending',
'oauth-login': 'running',
});
assert.equal(patch.runtimeState.flowState.openai.auth.oauthUrl, 'https://new.example.com/start');
assert.equal(patch.runtimeState.flowState.openai.plus.plusCheckoutTabId, 99);
});
@@ -0,0 +1,132 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('importSettingsBundle normalizes unsupported capability flags before persisting imported settings', async () => {
const api = new Function(`
const SETTINGS_EXPORT_SCHEMA_VERSION = 1;
const DEFAULT_REGISTRATION_EMAIL_STATE = { emailHistory: [] };
let persistedUpdates = null;
let stateUpdates = null;
let broadcastPayload = null;
let currentState = {
activeFlowId: 'site-a',
panelMode: 'sub2api',
signupMethod: 'phone',
plusModeEnabled: false,
phoneVerificationEnabled: false,
stepStatuses: {},
};
async function ensureManualInteractionAllowed() {
return currentState;
}
function buildPersistentSettingsPayload(settings = {}) {
return { ...settings };
}
function validateModeSwitchState() {
return {
ok: false,
errors: [{ code: 'panel_mode_unsupported', message: '当前 flow 不支持 SUB2API 面板模式。' }],
normalizedUpdates: {
panelMode: 'cpa',
plusModeEnabled: false,
phoneVerificationEnabled: false,
signupMethod: 'email',
},
};
}
function resolveSignupMethod(state = {}) {
return String(state?.signupMethod || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email';
}
async function setPersistentSettings(updates) {
persistedUpdates = { ...updates };
}
async function setState(updates) {
stateUpdates = { ...updates };
currentState = { ...currentState, ...updates };
}
function broadcastDataUpdate(payload) {
broadcastPayload = { ...payload };
}
async function getState() {
return { ...currentState };
}
${extractFunction('importSettingsBundle')}
return {
importSettingsBundle,
getPersistedUpdates: () => persistedUpdates,
getStateUpdates: () => stateUpdates,
getBroadcastPayload: () => broadcastPayload,
};
`)();
const result = await api.importSettingsBundle({
schemaVersion: 1,
settings: {
panelMode: 'sub2api',
plusModeEnabled: true,
phoneVerificationEnabled: true,
signupMethod: 'phone',
},
});
assert.deepEqual(api.getPersistedUpdates(), {
panelMode: 'cpa',
plusModeEnabled: false,
phoneVerificationEnabled: false,
signupMethod: 'email',
});
assert.equal(api.getStateUpdates().panelMode, 'cpa');
assert.equal(api.getStateUpdates().plusModeEnabled, false);
assert.equal(api.getStateUpdates().phoneVerificationEnabled, false);
assert.equal(api.getStateUpdates().signupMethod, 'email');
assert.equal(api.getBroadcastPayload().panelMode, 'cpa');
assert.equal(api.getBroadcastPayload().signupMethod, 'email');
assert.equal(result.signupMethod, 'email');
});
@@ -95,6 +95,51 @@ return {
assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true);
});
test('signup method resolution respects the shared flow capability registry when available', () => {
const api = new Function(`
const self = {
MultiPageFlowCapabilities: {
createFlowCapabilityRegistry() {
return {
resolveSidepanelCapabilities({ state = {}, signupMethod = 'email' } = {}) {
const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai';
return {
canUsePhoneSignup: phoneAllowed,
effectiveSignupMethod: signupMethod === 'phone' && phoneAllowed ? 'phone' : 'email',
};
},
};
},
},
};
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
${extractFunction('normalizeSignupMethod')}
${extractFunction('canUsePhoneSignup')}
${extractFunction('resolveSignupMethod')}
return {
canUsePhoneSignup,
resolveSignupMethod,
};
`)();
assert.equal(api.canUsePhoneSignup({
activeFlowId: 'site-a',
phoneVerificationEnabled: true,
signupMethod: 'phone',
}), false);
assert.equal(api.resolveSignupMethod({
activeFlowId: 'site-a',
phoneVerificationEnabled: true,
signupMethod: 'phone',
}), 'email');
assert.equal(api.resolveSignupMethod({
activeFlowId: 'openai',
phoneVerificationEnabled: true,
signupMethod: 'phone',
}), 'phone');
});
test('background step definitions resolve titles from the frozen signup method', () => {
const api = new Function(`
const captured = [];
@@ -132,6 +177,7 @@ return {
});
assert.deepEqual(api.getCaptured(), [{
activeFlowId: 'openai',
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
signupMethod: 'phone',
@@ -16,6 +16,76 @@ test('tab runtime module exposes a factory', () => {
assert.equal(typeof api?.createTabRuntime, 'function');
});
test('tab runtime accepts canonical openai-auth readiness for queued signup-page commands', async () => {
const runtimeSource = fs.readFileSync('background/tab-runtime.js', 'utf8');
const registrySource = fs.readFileSync('shared/source-registry.js', 'utf8');
const runtimeApi = new Function('self', `${runtimeSource}; return self.MultiPageBackgroundTabRuntime;`)({});
const registryApi = new Function('self', `${registrySource}; return self.MultiPageSourceRegistry;`)({});
const sourceRegistry = registryApi.createSourceRegistry();
const sentMessages = [];
let currentState = {
tabRegistry: {
'signup-page': { tabId: 91, ready: true },
},
sourceLastUrls: {
'signup-page': 'https://auth.openai.com/authorize',
},
};
const runtime = runtimeApi.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
get: async (tabId) => ({
id: tabId,
windowId: 1,
url: 'https://auth.openai.com/authorize',
status: 'complete',
}),
query: async () => [],
sendMessage: async (tabId, message) => {
if (message.type === 'PING') {
return { ok: true, source: 'openai-auth' };
}
sentMessages.push({ tabId, message });
return { ok: true };
},
},
},
getSourceLabel: (source) => source || 'unknown',
getState: async () => currentState,
matchesSourceUrlFamily: (source, candidateUrl, referenceUrl) => (
sourceRegistry.matchesSourceUrlFamily(source, candidateUrl, referenceUrl)
),
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sourceRegistry,
throwIfStopped: () => {},
});
assert.equal(await runtime.getTabId('openai-auth'), 91);
currentState = {
tabRegistry: {},
sourceLastUrls: {},
};
const queued = runtime.queueCommand('signup-page', { type: 'STEP2_TEST' }, 1000);
runtime.flushCommand('openai-auth', 55);
await assert.doesNotReject(queued);
assert.deepEqual(sentMessages, [{ tabId: 55, message: { type: 'STEP2_TEST' } }]);
await runtime.ensureContentScriptReadyOnTab('signup-page', 77, {
timeoutMs: 100,
});
assert.deepEqual(currentState.tabRegistry['openai-auth'], { tabId: 77, ready: true, windowId: 1 });
assert.equal(Object.prototype.hasOwnProperty.call(currentState.tabRegistry, 'signup-page'), false);
});
test('tab runtime caps per-attempt response timeout to the remaining resilient timeout budget', () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
+34
View File
@@ -81,6 +81,38 @@ return { detectScriptSource };
);
});
test('detectScriptSource maps OpenAI auth hosts to canonical openai-auth source', () => {
const bundle = [extractFunction('detectScriptSource')].join('\n');
const api = new Function(`
${bundle}
return { detectScriptSource };
`)();
assert.equal(
api.detectScriptSource({
url: 'https://auth.openai.com/create-account',
hostname: 'auth.openai.com',
}),
'openai-auth'
);
});
test('detectScriptSource returns unknown-source for unrecognized pages', () => {
const bundle = [extractFunction('detectScriptSource')].join('\n');
const api = new Function(`
${bundle}
return { detectScriptSource };
`)();
assert.equal(
api.detectScriptSource({
url: 'https://example.com/',
hostname: 'example.com',
}),
'unknown-source'
);
});
test('shouldReportReadyForFrame suppresses noisy plus checkout child frame ready logs', () => {
const bundle = [extractFunction('shouldReportReadyForFrame')].join('\n');
const api = new Function(`
@@ -91,6 +123,8 @@ 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);
assert.equal(api.shouldReportReadyForFrame('unknown-source', false), true);
assert.equal(api.shouldReportReadyForFrame('unknown-source', true), false);
});
test('getRuntimeScriptSource follows injected source overrides after utils is already loaded', () => {
+160
View File
@@ -0,0 +1,160 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('shared/flow-capabilities.js', 'utf8');
function loadApi() {
const scope = {};
return new Function('self', `${source}; return self.MultiPageFlowCapabilities;`)(scope);
}
test('flow capability registry keeps OpenAI phone signup available only when runtime locks allow it', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const enabledState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
panelMode: 'cpa',
phoneVerificationEnabled: true,
plusModeEnabled: false,
contributionMode: false,
signupMethod: 'phone',
},
});
assert.equal(enabledState.canUsePhoneSignup, true);
assert.equal(enabledState.effectiveSignupMethod, 'phone');
assert.equal(enabledState.shouldWarnCpaPhoneSignup, true);
assert.deepEqual(enabledState.effectiveSignupMethods, ['email', 'phone']);
const plusLockedState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'openai',
panelMode: 'sub2api',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: false,
signupMethod: 'phone',
},
});
assert.equal(plusLockedState.canUsePhoneSignup, false);
assert.equal(plusLockedState.effectiveSignupMethod, 'email');
assert.equal(plusLockedState.shouldWarnCpaPhoneSignup, false);
assert.deepEqual(plusLockedState.effectiveSignupMethods, ['email']);
});
test('flow capability registry defaults unknown flows to minimal non-phone capabilities', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry();
const capabilityState = registry.resolveSidepanelCapabilities({
state: {
activeFlowId: 'site-a',
panelMode: 'codex2api',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: true,
signupMethod: 'phone',
},
});
assert.equal(capabilityState.activeFlowId, 'site-a');
assert.equal(capabilityState.canShowPhoneSettings, false);
assert.equal(capabilityState.canShowPlusSettings, false);
assert.equal(capabilityState.canShowLuckmail, false);
assert.equal(capabilityState.canUsePhoneSignup, false);
assert.equal(capabilityState.effectiveSignupMethod, 'email');
assert.equal(capabilityState.panelMode, 'codex2api');
assert.deepEqual(capabilityState.supportedPanelModes, []);
});
test('flow capability registry exposes shared auto-run validation for phone locks and panel support', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry({
flowCapabilities: {
openai: api.FLOW_CAPABILITIES.openai,
'site-a': {
...api.DEFAULT_FLOW_CAPABILITIES,
supportsPlatformBinding: ['cpa'],
},
},
});
const plusLockedResult = registry.validateAutoRunStart({
state: {
activeFlowId: 'openai',
panelMode: 'cpa',
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: false,
},
});
assert.equal(plusLockedResult.ok, false);
assert.equal(plusLockedResult.errors[0].code, 'phone_signup_plus_mode_locked');
const unsupportedPanelResult = registry.validateAutoRunStart({
state: {
activeFlowId: 'site-a',
panelMode: 'sub2api',
signupMethod: 'email',
},
});
assert.equal(unsupportedPanelResult.ok, false);
assert.equal(unsupportedPanelResult.errors[0].code, 'panel_mode_unsupported');
});
test('flow capability registry normalizes unsupported mode switches back to the effective capability set', () => {
const api = loadApi();
const registry = api.createFlowCapabilityRegistry({
flowCapabilities: {
openai: api.FLOW_CAPABILITIES.openai,
'site-a': {
...api.DEFAULT_FLOW_CAPABILITIES,
supportsPlatformBinding: ['cpa'],
},
},
});
const validation = registry.validateModeSwitch({
state: {
activeFlowId: 'site-a',
panelMode: 'sub2api',
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: true,
contributionMode: true,
},
changedKeys: [
'panelMode',
'signupMethod',
'phoneVerificationEnabled',
'plusModeEnabled',
'contributionMode',
],
});
assert.equal(validation.ok, false);
assert.deepEqual(validation.normalizedUpdates, {
panelMode: 'cpa',
signupMethod: 'email',
phoneVerificationEnabled: false,
plusModeEnabled: false,
contributionMode: false,
});
assert.deepEqual(
validation.errors.map((entry) => entry.code),
[
'panel_mode_unsupported',
'plus_mode_unsupported',
'contribution_mode_unsupported',
'phone_verification_unsupported',
'phone_signup_flow_unsupported',
]
);
});
+33
View File
@@ -185,6 +185,15 @@ test('extractVerificationCode returns first six-digit code from multilingual mai
assert.equal(extractVerificationCode('No code here'), null);
});
test('extractVerificationCode supports runtime mail rule patterns', () => {
assert.equal(
extractVerificationCode('Mailbox notice: use pin A-778899 to continue.', {
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
}),
'778899'
);
});
test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => {
assert.equal(
extractVerificationCodeFromMessage({
@@ -214,6 +223,30 @@ test('extractVerificationCodeFromMessage reads code from the latest message subj
);
});
test('pickVerificationMessageWithTimeFallback supports required keyword hints and runtime code patterns', () => {
const messages = [
{
id: 'mail-1',
subject: 'Security center',
from: { emailAddress: { address: 'alerts@example.com' } },
bodyPreview: 'Use pin A-661122 to continue',
receivedDateTime: '2026-04-14T10:06:00.000Z',
},
];
const result = pickVerificationMessageWithTimeFallback(messages, {
afterTimestamp: 0,
senderFilters: [],
subjectFilters: [],
requiredKeywords: ['security'],
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
excludeCodes: [],
});
assert.equal(result.match?.code, '661122');
assert.equal(result.usedTimeFallback, false);
});
test('getHotmailListToggleLabel reflects expanded state and account count', () => {
assert.equal(getHotmailListToggleLabel(false, 0), '展开列表');
assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7');
+31
View File
@@ -54,6 +54,8 @@ function extractFunction(name) {
test('readOpenedMailBody falls back to thread detail pane and extracts verification code', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
extractFunction('getOpenedMailBodyRoot'),
extractFunction('readOpenedMailBody'),
@@ -83,6 +85,8 @@ return { readOpenedMailBody, extractVerificationCode };
test('extractVerificationCode matches the new suspicious log-in mail body', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
@@ -95,6 +99,27 @@ return { extractVerificationCode };
assert.equal(api.extractVerificationCode(bodyText), '982219');
});
test('extractVerificationCode supports runtime mail rule patterns', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
const api = new Function(`
${bundle}
return { extractVerificationCode };
`)();
const bodyText = 'Mailbox notice\nUse verification pin A-445566 to continue.';
assert.equal(
api.extractVerificationCode(bodyText, {
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
}),
'445566'
);
});
test('readOpenedMailBody ignores conversation list rows when no detail pane is open', () => {
const bundle = [
extractFunction('normalizeText'),
@@ -199,6 +224,8 @@ test('icloud poll session baseline is reused across calls and enables fallback a
extractFunction('normalizeText'),
extractFunction('getThreadItemMetadata'),
extractFunction('buildItemSignature'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
extractFunction('normalizePollSessionKey'),
extractFunction('getOrCreatePollSessionBaseline'),
@@ -300,6 +327,8 @@ test('icloud step8 polling finds a visible first-row code immediately', async ()
extractFunction('normalizeText'),
extractFunction('getThreadItemMetadata'),
extractFunction('buildItemSignature'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
extractFunction('normalizePollSessionKey'),
extractFunction('getOrCreatePollSessionBaseline'),
@@ -378,6 +407,8 @@ test('icloud step8 visible first-row code still respects excluded codes', async
extractFunction('normalizeText'),
extractFunction('getThreadItemMetadata'),
extractFunction('buildItemSignature'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
extractFunction('normalizePollSessionKey'),
extractFunction('getOrCreatePollSessionBaseline'),
+29
View File
@@ -166,6 +166,8 @@ test('readOpenedMailText prefers opened body content that contains the verificat
extractFunction('collectOpenedMailTextCandidates'),
extractFunction('selectOpenedMailTextCandidate'),
extractFunction('readOpenedMailText'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
@@ -216,6 +218,8 @@ test('openMailAndGetMessageText reads opened body text and returns to inbox', as
extractFunction('readOpenedMailText'),
extractFunction('returnToInbox'),
extractFunction('openMailAndGetMessageText'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
@@ -289,6 +293,8 @@ test('openMailAndGetMessageText ignores stale pre-open text that contains an old
extractFunction('readOpenedMailText'),
extractFunction('returnToInbox'),
extractFunction('openMailAndGetMessageText'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
@@ -359,6 +365,8 @@ return { openMailAndGetMessageText, mailItem };
test('extractVerificationCode matches the new suspicious log-in mail body', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
@@ -371,6 +379,27 @@ return { extractVerificationCode };
assert.equal(api.extractVerificationCode(bodyText), '982219');
});
test('extractVerificationCode supports runtime mail rule patterns', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
const api = new Function(`
${bundle}
return { extractVerificationCode };
`)();
const bodyText = 'Security Center\nUse verification pin A-778899 to continue.';
assert.equal(
api.extractVerificationCode(bodyText, {
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
}),
'778899'
);
});
test('handlePollEmail ignores same-minute old snapshot mail before fallback', async () => {
const bundle = [
extractFunction('normalizeText'),
+60 -2
View File
@@ -327,6 +327,7 @@ return {
test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => {
const bundle = [
extractFunction('extractEmails'),
extractFunction('normalizeTargetEmailHints'),
extractFunction('extractForwardedTargetEmails'),
extractFunction('emailMatchesTarget'),
extractFunction('getTargetEmailMatchState'),
@@ -419,6 +420,34 @@ return {
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-2']);
});
test('getTargetEmailMatchState decodes generic forwarded bounce aliases without OpenAI-specific domains', () => {
const bundle = [
extractFunction('extractEmails'),
extractFunction('normalizeTargetEmailHints'),
extractFunction('extractForwardedTargetEmails'),
extractFunction('emailMatchesTarget'),
extractFunction('getTargetEmailMatchState'),
].join('\n');
const api = new Function(`
${bundle}
return { getTargetEmailMatchState };
`)();
const state = api.getTargetEmailMatchState(
'Return-Path: <bounce+notice-expected.user=example.com@mailer.forwarder.net>',
'expected.user@example.com',
{
targetEmailHints: ['expected.user@example.com', 'expected.user=example.com'],
}
);
assert.deepEqual(state, {
matches: true,
hasExplicitEmail: true,
});
});
test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => {
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
@@ -565,7 +594,9 @@ return {
test('extractVerificationCode strict mode matches the new suspicious log-in mail body', () => {
const bundle = [
extractFunction('extractStrictChatGPTVerificationCode'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractLegacyStrictVerificationCode'),
extractFunction('isLikelyCompactTimeValue'),
extractFunction('isLikelyHeaderTimestampCode'),
extractFunction('findSafeStandaloneSixDigitCode'),
@@ -582,9 +613,36 @@ return { extractVerificationCode };
assert.equal(api.extractVerificationCode(bodyText, false), '982219');
});
test('extractVerificationCode supports runtime mail rule patterns', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractLegacyStrictVerificationCode'),
extractFunction('isLikelyCompactTimeValue'),
extractFunction('isLikelyHeaderTimestampCode'),
extractFunction('findSafeStandaloneSixDigitCode'),
extractFunction('extractVerificationCode'),
].join('\n');
const api = new Function(`
${bundle}
return { extractVerificationCode };
`)();
const bodyText = 'System alert\nUse verification pin A-556677 to continue.';
assert.equal(
api.extractVerificationCode(bodyText, {
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
}),
'556677'
);
});
test('extractVerificationCode ignores compact header time before fallback code', () => {
const bundle = [
extractFunction('extractStrictChatGPTVerificationCode'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractLegacyStrictVerificationCode'),
extractFunction('isLikelyCompactTimeValue'),
extractFunction('isLikelyHeaderTimestampCode'),
extractFunction('findSafeStandaloneSixDigitCode'),
+30
View File
@@ -64,6 +64,36 @@ test('extractVerificationCodeFromMessages 支持显式过滤条件并跳过排
});
});
test('extractVerificationCodeFromMessages supports keyword hints and runtime code patterns without OpenAI-specific fallback', () => {
const result = extractVerificationCodeFromMessages([
{
From: { EmailAddress: { Address: 'alerts@example.com' } },
Subject: 'Security center',
BodyPreview: 'Use pin A-551199 to continue',
ReceivedDateTime: '2026-04-14T10:07:00.000Z',
Id: 'mail-1',
},
{
From: { EmailAddress: { Address: 'news@example.com' } },
Subject: 'Newsletter',
BodyPreview: 'pin A-000000',
ReceivedDateTime: '2026-04-14T10:06:00.000Z',
Id: 'mail-2',
},
], {
filterAfterTimestamp: 0,
senderFilters: [],
subjectFilters: [],
requiredKeywords: ['security'],
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
excludeCodes: [],
});
assert.equal(result.code, '551199');
assert.equal(result.messageId, 'mail-1');
assert.equal(result.sender, 'alerts@example.com');
});
test('normalizeMailboxId 将 Junk 归一为微软邮箱夹 ID', () => {
assert.equal(normalizeMailboxId('INBOX'), 'inbox');
assert.equal(normalizeMailboxId('junk'), 'junkemail');
+139
View File
@@ -0,0 +1,139 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/qq-mail.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('qq extractVerificationCode supports runtime mail rule patterns', () => {
const bundle = [
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
].join('\n');
const api = new Function(`
${bundle}
return { extractVerificationCode };
`)();
assert.equal(
api.extractVerificationCode('Mailbox notice: use pin A-441122 to continue.', {
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
}),
'441122'
);
});
test('qq handlePollEmail forwards runtime code patterns to new-mail matching', async () => {
const bundle = [
extractFunction('getCurrentMailIds'),
extractFunction('normalizeRulePatternList'),
extractFunction('extractCodeByRulePatterns'),
extractFunction('extractVerificationCode'),
extractFunction('handlePollEmail'),
].join('\n');
const api = new Function(`
let currentItems = [];
let refreshCount = 0;
function createMailItem(mailId, sender, subject, digest) {
return {
getAttribute(name) {
if (name === 'data-mailid') return mailId;
return '';
},
querySelector(selector) {
if (selector === '.cmp-account-nick') return { textContent: sender };
if (selector === '.mail-subject') return { textContent: subject };
if (selector === '.mail-digest') return { textContent: digest };
return null;
},
};
}
const document = {
querySelectorAll(selector) {
if (selector === '.mail-list-page-item[data-mailid]') {
return currentItems;
}
return [];
},
};
async function waitForElement() {
return true;
}
async function refreshInbox() {
refreshCount += 1;
if (refreshCount >= 1) {
currentItems = [
createMailItem('mail-1', 'alerts@example.com', 'Security center', 'Use pin A-551188 to continue'),
];
}
}
async function sleep() {}
function log() {}
${bundle}
return { handlePollEmail };
`)();
const result = await api.handlePollEmail(4, {
senderFilters: ['alerts'],
subjectFilters: ['security'],
maxAttempts: 2,
intervalMs: 1,
codePatterns: [{ source: 'pin\\s+A-(\\d{6})', flags: 'i' }],
});
assert.equal(result.code, '551188');
});
@@ -196,6 +196,107 @@ test('startAutoRunFromCurrentSettings freezes run count before async settings sy
assert.equal(events[3].message.payload.totalRuns, 20);
});
test('startAutoRunFromCurrentSettings blocks when shared flow capability validation fails', async () => {
const bundle = [
extractFunction('normalizePendingAutoRunStartRunCount'),
extractFunction('registerPendingAutoRunStartRunCount'),
extractFunction('clearPendingAutoRunStartRunCount'),
extractFunction('startAutoRunFromCurrentSettings'),
].join('\n');
const api = new Function(`
const events = [];
const latestState = {
activeFlowId: 'site-a',
panelMode: 'cpa',
signupMethod: 'phone',
contributionMode: false,
phoneVerificationEnabled: true,
};
const inputAutoSkipFailures = { checked: false };
const inputContributionNickname = { value: 'tester' };
const inputContributionQq = { value: '123456' };
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
const inputAutoDelayEnabled = { checked: false };
const inputAutoDelayMinutes = { value: '30' };
const btnAutoRun = { disabled: false, innerHTML: '' };
const inputRunCount = { disabled: false, value: '1' };
const inputPhoneVerificationEnabled = { checked: true };
const inputPlusModeEnabled = { checked: false };
let runCountValue = 1;
let pendingAutoRunStartTotalRuns = 0;
let pendingAutoRunStartExpiresAt = 0;
const chrome = {
runtime: {
async sendMessage(message) {
events.push({ type: 'send', message });
return { ok: true };
},
},
};
const console = {
warn(...args) {
events.push({ type: 'warn', args });
},
};
const window = {
MultiPageFlowCapabilities: {
createFlowCapabilityRegistry() {
return {
validateAutoRunStart() {
return {
ok: false,
errors: [{ message: '当前 flow 不支持手机号注册。' }],
};
},
};
},
},
};
async function sendSidepanelMessage(message) {
return chrome.runtime.sendMessage(message);
}
async function persistCurrentSettingsForAction() {
events.push({ type: 'sync-settings' });
}
function getRunCountValue() { return Math.max(1, Number(runCountValue) || 1); }
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
function shouldOfferAutoModeChoice() { return false; }
async function openAutoStartChoiceDialog() { throw new Error('should not be called'); }
function getFirstUnfinishedStep() { return 1; }
function getRunningSteps() { return []; }
function shouldWarnAutoRunFallbackRisk() { return false; }
function isAutoRunFallbackRiskPromptDismissed() { return false; }
async function openAutoRunFallbackRiskConfirmModal() { throw new Error('should not be called'); }
function setAutoRunFallbackRiskPromptDismissed() {}
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
async function refreshContributionContentHint() {
events.push({ type: 'refresh' });
return null;
}
async function ensureGpcApiKeyReadyForStart() {
return true;
}
${bundle}
return {
startAutoRunFromCurrentSettings,
getEvents() {
return events;
},
};
`)();
await assert.rejects(
() => api.startAutoRunFromCurrentSettings(),
/当前 flow 不支持手机号注册。/
);
assert.deepEqual(
api.getEvents().map((entry) => entry.type),
['refresh', 'sync-settings']
);
});
test('persistCurrentSettingsForAction forces a silent save even when settings are not marked dirty', async () => {
const bundle = [
extractFunction('waitForSettingsSaveIdle'),
@@ -294,6 +294,58 @@ return {
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false);
});
test('sidepanel phone signup gating can follow the shared flow capability registry', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
extractFunction('normalizePanelMode'),
extractFunction('canSelectPhoneSignupMethod'),
extractFunction('shouldWarnCpaPhoneSignup'),
].join('\n');
const api = new Function(`
const window = {
MultiPageFlowCapabilities: {
createFlowCapabilityRegistry() {
return {
resolveSidepanelCapabilities({ state = {}, panelMode = 'cpa', signupMethod = 'email' } = {}) {
const phoneAllowed = String(state?.activeFlowId || '').trim().toLowerCase() === 'openai';
return {
canSelectPhoneSignup: phoneAllowed,
shouldWarnCpaPhoneSignup: phoneAllowed && signupMethod === 'phone' && panelMode === 'cpa',
};
},
};
},
},
};
let latestState = {
activeFlowId: 'site-a',
contributionMode: false,
panelMode: 'cpa',
};
const inputPhoneVerificationEnabled = { checked: true };
const inputPlusModeEnabled = { checked: false };
function getSelectedPanelMode() { return 'cpa'; }
function getSelectedSignupMethod() { return 'phone'; }
function isCpaPhoneSignupPromptDismissed() { return false; }
${bundle}
return {
canSelectPhoneSignupMethod,
shouldWarnCpaPhoneSignup,
setFlow(flowId) {
latestState.activeFlowId = flowId;
},
};
`)();
assert.equal(api.canSelectPhoneSignupMethod(), false);
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), false);
api.setFlow('openai');
assert.equal(api.canSelectPhoneSignupMethod(), true);
assert.equal(api.shouldWarnCpaPhoneSignup('phone', 'cpa'), true);
});
test('manual step 3 uses phone identity without requiring registration email', () => {
const api = new Function(`
let latestState = { signupMethod: 'phone', phoneVerificationEnabled: true, signupPhoneNumber: '+441111111111', accountIdentifierType: 'phone', accountIdentifier: '+441111111111' };
+69 -4
View File
@@ -106,7 +106,7 @@ return {
assert.deepEqual(api.getStepIds(), [7]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gopay', signupMethod: 'email' },
});
assert.deepEqual(api.calls[1], { type: 'render', stepIds: [7] });
});
@@ -116,6 +116,15 @@ test('sidepanel normalizeSignupMethod stays independent from signup constants du
assert.doesNotMatch(source, /SIGNUP_METHOD_(PHONE|EMAIL)/);
});
test('sidepanel initializes latestState before bootstrapping shared step definitions', () => {
const latestStateIndex = sidepanelSource.indexOf('let latestState = null;');
const bootstrapIndex = sidepanelSource.indexOf('let stepDefinitions = getStepDefinitionsForMode(false, {');
assert.notEqual(latestStateIndex, -1);
assert.notEqual(bootstrapIndex, -1);
assert.ok(latestStateIndex < bootstrapIndex);
});
test('sidepanel signup method UI syncs shared step definitions with the selected signup method', () => {
const source = extractFunction('updateSignupMethodUI');
assert.match(source, /syncStepDefinitionsForMode\(/);
@@ -124,8 +133,8 @@ test('sidepanel signup method UI syncs shared step definitions with the selected
test('sidepanel applies restored signup method when rebuilding shared step definitions on load', () => {
const source = extractFunction('applySettingsState');
assert.match(source, /syncStepDefinitionsForMode\(Boolean\(state\?\.plusModeEnabled\),\s*\{/);
assert.match(source, /signupMethod:\s*state\?\.signupMethod/);
assert.match(source, /resolveStepDefinitionCapabilityState\(state/);
assert.match(source, /signupMethod:\s*stepDefinitionState\.signupMethod/);
});
test('sidepanel Plus UI hides PayPal account selector while GoPay is selected', () => {
@@ -165,6 +174,62 @@ return { updatePlusModeUI, selectPlusPaymentMethod, rowPayPalAccount };
assert.equal(api.rowPayPalAccount.style.display, '');
});
test('sidepanel Plus UI can hide Plus controls when the shared flow capability registry disables them', () => {
const bundle = [
extractFunction('normalizePlusPaymentMethod'),
extractFunction('getSelectedPlusPaymentMethod'),
extractFunction('normalizeGpcHelperPhoneModeValue'),
extractFunction('getGpcHelperAutoModeEnabled'),
extractFunction('normalizeGpcAutoModePermissionValue'),
extractFunction('getGpcAutoModePermissionFromPayload'),
extractFunction('shouldPreserveSelectedGpcAutoMode'),
extractFunction('hasGpcAutoModePermissionField'),
extractFunction('isGpcAutoModePermissionDenied'),
extractFunction('normalizeGpcOtpChannelValue'),
extractFunction('updatePlusModeUI'),
].join('\n');
const api = new Function(`
const window = {
MultiPageFlowCapabilities: {
createFlowCapabilityRegistry() {
return {
resolveSidepanelCapabilities() {
return {
canShowPlusSettings: false,
runtimeLocks: { plusModeEnabled: false },
};
},
};
},
},
};
let latestState = { plusPaymentMethod: 'paypal' };
const inputPlusModeEnabled = { checked: true };
const rowPlusMode = { style: { display: '' } };
const selectPlusPaymentMethod = { value: 'paypal', style: { display: '' } };
const rowPlusPaymentMethod = { style: { display: '' } };
const rowPayPalAccount = { style: { display: '' } };
const GPC_HELPER_PHONE_MODE_AUTO = 'auto';
const GPC_HELPER_PHONE_MODE_MANUAL = 'manual';
${bundle}
return {
rowPlusMode,
rowPlusPaymentMethod,
rowPayPalAccount,
selectPlusPaymentMethod,
updatePlusModeUI,
};
`)();
api.updatePlusModeUI();
assert.equal(api.rowPlusMode.style.display, 'none');
assert.equal(api.rowPlusPaymentMethod.style.display, 'none');
assert.equal(api.rowPayPalAccount.style.display, 'none');
assert.equal(api.selectPlusPaymentMethod.style.display, 'none');
});
test('sidepanel step definitions keep GPC helper mode distinct', () => {
const bundle = [
extractFunction('normalizeSignupMethod'),
@@ -210,7 +275,7 @@ return {
assert.deepEqual(api.getStepIds(), [13]);
assert.deepEqual(api.calls[0], {
type: 'getSteps',
options: { plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' },
options: { activeFlowId: 'openai', plusModeEnabled: true, plusPaymentMethod: 'gpc-helper', signupMethod: 'email' },
});
});
+56
View File
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports shared source registry module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /shared\/source-registry\.js/);
});
test('manifest loads shared source registry before content utils in static bundles', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
for (const entry of manifest.content_scripts || []) {
const scripts = Array.isArray(entry.js) ? entry.js : [];
if (!scripts.includes('content/utils.js')) continue;
assert.ok(scripts.includes('shared/source-registry.js'));
assert.ok(
scripts.indexOf('shared/source-registry.js') < scripts.indexOf('content/utils.js'),
'shared/source-registry.js must load before content/utils.js'
);
}
});
test('shared source registry exposes canonical source, alias, detection, and ready policies', () => {
const source = fs.readFileSync('shared/source-registry.js', 'utf8');
const api = new Function('self', `${source}; return self.MultiPageSourceRegistry;`)({});
const registry = api.createSourceRegistry();
assert.equal(registry.resolveCanonicalSource('signup-page'), 'openai-auth');
assert.deepEqual(registry.getSourceKeys('signup-page'), ['openai-auth', 'signup-page']);
assert.equal(registry.getSourceLabel('openai-auth'), '认证页');
assert.equal(
registry.matchesSourceUrlFamily(
'openai-auth',
'https://chatgpt.com/',
'https://auth.openai.com/authorize?client_id=test'
),
true
);
assert.equal(
registry.detectSourceFromLocation({
url: 'https://auth.openai.com/create-account',
hostname: 'auth.openai.com',
}),
'openai-auth'
);
assert.equal(
registry.detectSourceFromLocation({
url: 'https://example.com/',
hostname: 'example.com',
}),
'unknown-source'
);
assert.equal(registry.shouldReportReadyForFrame('mail-163', true), false);
assert.equal(registry.shouldReportReadyForFrame('unknown-source', false), false);
assert.equal(registry.getCleanupOwnerSource('oauth-localhost-callback'), 'openai-auth');
});
+6
View File
@@ -16,6 +16,7 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.equal(Array.isArray(steps), true);
assert.equal(steps.length, 10);
assert.equal(steps.every((step) => step.flowId === 'openai'), true);
assert.deepStrictEqual(
steps.map((step) => step.order),
steps.map((step) => step.order).slice().sort((left, right) => left - right)
@@ -68,6 +69,11 @@ test('step definitions module exposes ordered normal and Plus step metadata', ()
assert.equal(api.getPlusPaymentStepTitle({ plusModeEnabled: true, plusPaymentMethod: 'gopay' }), '');
assert.deepStrictEqual(api.getStepIds({ plusModeEnabled: true }), [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]);
assert.equal(api.getLastStepId({ plusModeEnabled: true }), 13);
assert.equal(api.hasFlow('openai'), true);
assert.equal(api.hasFlow('site-a'), false);
assert.deepStrictEqual(api.getRegisteredFlowIds(), ['openai']);
assert.deepStrictEqual(api.getSteps({ activeFlowId: 'site-a' }), []);
assert.equal(api.getStepById(2, { activeFlowId: 'site-a' }), null);
assert.equal(plusSteps[5].title, '创建 Plus Checkout');
assert.equal(plusSteps[7].title, 'PayPal 登录与授权');
+37
View File
@@ -9,6 +9,7 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundVeri
function createVerificationFlowTestHelpers(overrides = {}) {
return api.createVerificationFlowHelpers({
addLog: async () => {},
buildVerificationPollPayload: null,
chrome: {
tabs: {
update: async () => {},
@@ -43,6 +44,42 @@ function createVerificationFlowTestHelpers(overrides = {}) {
});
}
test('verification flow prefers injected verification poll payload builder when provided', () => {
const helpers = createVerificationFlowTestHelpers({
buildVerificationPollPayload: (step, state, overrides = {}) => ({
flowId: state.activeFlowId || 'openai',
ruleId: 'custom-rule',
step,
senderFilters: ['custom-sender'],
subjectFilters: ['custom-subject'],
targetEmail: state.email,
maxAttempts: 9,
intervalMs: 4321,
...overrides,
}),
});
assert.deepStrictEqual(
helpers.getVerificationPollPayload(4, {
activeFlowId: 'openai',
email: 'user@example.com',
}, {
excludeCodes: ['111111'],
}),
{
flowId: 'openai',
ruleId: 'custom-rule',
step: 4,
senderFilters: ['custom-sender'],
subjectFilters: ['custom-subject'],
targetEmail: 'user@example.com',
maxAttempts: 9,
intervalMs: 4321,
excludeCodes: ['111111'],
}
);
});
test('verification flow keeps 2925 polling cadence in the default payload', () => {
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},