fix(contribution): dedupe localhost callback submission
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
} = deps;
|
||||
|
||||
let listenersBound = false;
|
||||
const inFlightCapturedCallbackTasks = new Map();
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
@@ -110,6 +111,29 @@
|
||||
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
||||
}
|
||||
|
||||
function runCapturedCallbackOnce(callbackUrl, executor) {
|
||||
const normalizedUrl = normalizeString(callbackUrl);
|
||||
if (!normalizedUrl) {
|
||||
return Promise.resolve().then(executor);
|
||||
}
|
||||
|
||||
const existingTask = inFlightCapturedCallbackTasks.get(normalizedUrl);
|
||||
if (existingTask) {
|
||||
return existingTask;
|
||||
}
|
||||
|
||||
let task = null;
|
||||
task = Promise.resolve()
|
||||
.then(executor)
|
||||
.finally(() => {
|
||||
if (inFlightCapturedCallbackTasks.get(normalizedUrl) === task) {
|
||||
inFlightCapturedCallbackTasks.delete(normalizedUrl);
|
||||
}
|
||||
});
|
||||
inFlightCapturedCallbackTasks.set(normalizedUrl, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
function normalizeContributionSource(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
return normalized === CONTRIBUTION_SOURCE_SUB2API
|
||||
@@ -513,42 +537,44 @@
|
||||
}
|
||||
|
||||
async function handleCapturedCallback(rawUrl, metadata = {}) {
|
||||
const currentState = await getState();
|
||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||
return currentState;
|
||||
}
|
||||
if (!isContributionCallbackUrl(rawUrl, currentState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeString(rawUrl);
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
return runCapturedCallbackOnce(normalizedUrl, async () => {
|
||||
const currentState = await getState();
|
||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||
return currentState;
|
||||
}
|
||||
if (!isContributionCallbackUrl(normalizedUrl, currentState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
return await submitContributionCallback(normalizedUrl, {
|
||||
reason: metadata.source || 'navigation',
|
||||
stateOverride: await getState(),
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
return await submitContributionCallback(normalizedUrl, {
|
||||
reason: metadata.source || 'navigation',
|
||||
stateOverride: await getState(),
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function pollContributionStatus(options = {}) {
|
||||
@@ -696,7 +722,7 @@
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
|
||||
const candidateUrl = normalizeString(changeInfo?.url);
|
||||
if (!candidateUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -510,6 +510,157 @@ test('contribution oauth manager starts session, opens auth url, submits callbac
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
|
||||
});
|
||||
|
||||
test('contribution oauth manager deduplicates concurrent callback captures for the same localhost url', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
const broadcasts = [];
|
||||
let submitCallCount = 0;
|
||||
let resolveSubmitRequest = null;
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthState: 'oauth-state-001',
|
||||
contributionStatus: 'waiting',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/submit-callback')) {
|
||||
submitCallCount += 1;
|
||||
await new Promise((resolve) => {
|
||||
resolveSubmitRequest = resolve;
|
||||
});
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, { ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate(updates) {
|
||||
broadcasts.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
const callbackUrl = 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001';
|
||||
const firstTask = manager.handleCapturedCallback(callbackUrl, { source: 'tabs.onUpdated' });
|
||||
const secondTask = manager.handleCapturedCallback(callbackUrl, { source: 'webNavigation.onCommitted' });
|
||||
|
||||
for (let round = 0; round < 10 && submitCallCount === 0; round += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.equal(submitCallCount, 1);
|
||||
assert.equal(
|
||||
broadcasts.filter((entry) => entry.contributionCallbackStatus === 'captured').length,
|
||||
1
|
||||
);
|
||||
|
||||
assert.equal(typeof resolveSubmitRequest, 'function');
|
||||
resolveSubmitRequest();
|
||||
const [firstState, secondState] = await Promise.all([firstTask, secondTask]);
|
||||
|
||||
assert.equal(firstState.contributionCallbackStatus, 'submitted');
|
||||
assert.equal(secondState.contributionCallbackStatus, 'submitted');
|
||||
assert.equal(fetchCalls.filter((call) => String(call.url).endsWith('/submit-callback')).length, 1);
|
||||
});
|
||||
|
||||
test('contribution oauth manager ignores tabs.onUpdated events without a real url change', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
const addedListeners = {};
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
contributionSource: 'sub2api',
|
||||
contributionTargetGroupName: 'codex号池',
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthState: 'oauth-state-001',
|
||||
contributionStatus: 'waiting',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
return createMockResponse(true, 200, { ok: true, status: 'processing' });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate(updates) {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
onUpdated: {
|
||||
addListener(listener) {
|
||||
addedListeners.onTabUpdated = listener;
|
||||
},
|
||||
},
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
manager.ensureCallbackListeners();
|
||||
assert.equal(typeof addedListeners.onTabUpdated, 'function');
|
||||
|
||||
addedListeners.onTabUpdated(
|
||||
88,
|
||||
{ status: 'complete' },
|
||||
{ url: 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001' }
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
|
||||
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
Reference in New Issue
Block a user