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:
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -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)');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 登录与授权');
|
||||
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
Reference in New Issue
Block a user