fix(tabs): lock automation to selected window

This commit is contained in:
朴圣佑
2026-05-12 11:06:56 +08:00
parent ec3c3808e6
commit 2bb34f9064
13 changed files with 682 additions and 36 deletions
@@ -188,3 +188,75 @@ test('tab runtime waitForTabStableComplete waits through a late navigation after
assert.equal(result?.status, 'complete');
assert.ok(getCalls >= 4);
});
test('tab runtime opens new automation tabs in the locked window', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
const created = [];
const runtime = api.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
create: async (payload) => {
created.push(payload);
return { id: 17, windowId: payload.windowId, url: payload.url };
},
get: async () => ({ id: 17, windowId: 100, url: 'https://example.com' }),
query: async () => [],
onUpdated: {
addListener: () => {},
removeListener: () => {},
},
},
},
getSourceLabel: (sourceName) => sourceName || 'unknown',
getState: async () => ({
automationWindowId: 100,
tabRegistry: {},
sourceLastUrls: {},
}),
matchesSourceUrlFamily: () => false,
setState: async () => {},
throwIfStopped: () => {},
});
await runtime.reuseOrCreateTab('signup-page', 'https://example.com');
assert.equal(created.length, 1);
assert.equal(created[0].windowId, 100);
});
test('tab runtime scopes tab queries to the locked automation window', async () => {
const source = fs.readFileSync('background/tab-runtime.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundTabRuntime;`)(globalScope);
const queries = [];
const runtime = api.createTabRuntime({
LOG_PREFIX: '[test]',
addLog: async () => {},
chrome: {
tabs: {
get: async () => ({ id: 1, windowId: 22, url: 'https://example.com' }),
query: async (queryInfo) => {
queries.push(queryInfo);
return [];
},
},
},
getSourceLabel: (sourceName) => sourceName || 'unknown',
getState: async () => ({
automationWindowId: 22,
tabRegistry: {},
sourceLastUrls: {},
}),
matchesSourceUrlFamily: () => false,
setState: async () => {},
throwIfStopped: () => {},
});
await runtime.queryTabsInAutomationWindow({ active: true, currentWindow: true });
assert.deepEqual(queries[0], { active: true, windowId: 22 });
});
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/message-router.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('message router locks automation window from sidepanel payload', async () => {
const bundle = [
extractFunction('normalizeAutomationWindowId'),
extractFunction('resolveAutomationWindowIdFromMessage'),
extractFunction('lockAutomationWindowFromMessage'),
].join('\n');
const api = new Function(`
const updates = [];
async function setState(update) {
updates.push(update);
}
${bundle}
return {
lockAutomationWindowFromMessage,
updates,
};
`)();
const windowId = await api.lockAutomationWindowFromMessage({
payload: { automationWindowId: 88 },
});
assert.equal(windowId, 88);
assert.deepEqual(api.updates, [{ automationWindowId: 88 }]);
});
test('message router can fall back to sender tab window id', async () => {
const bundle = [
extractFunction('normalizeAutomationWindowId'),
extractFunction('resolveAutomationWindowIdFromMessage'),
].join('\n');
const api = new Function(`
${bundle}
return { resolveAutomationWindowIdFromMessage };
`)();
assert.equal(
api.resolveAutomationWindowIdFromMessage({}, { tab: { windowId: 19 } }),
19
);
});
+37
View File
@@ -18,6 +18,7 @@ function createExecutor({
getTabId = async (source) => (source === 'paypal-flow' ? 1 : null),
isTabAlive = async () => true,
queryTabs = [],
queryTabsInAutomationWindow = null,
}) {
const api = loadModule();
const events = {
@@ -61,6 +62,7 @@ function createExecutor({
ensureContentScriptReadyOnTabUntilStopped: async () => {},
getTabId,
isTabAlive,
...(typeof queryTabsInAutomationWindow === 'function' ? { queryTabsInAutomationWindow } : {}),
sendTabMessageUntilStopped: async (_tabId, _source, message) => {
events.messages.push(message.type);
if (message.type === 'PAYPAL_GET_STATE') {
@@ -406,6 +408,41 @@ test('PayPal approve discovers an already open unregistered PayPal tab', async (
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal approve discovers PayPal tabs through the locked automation window query', async () => {
const queries = [];
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: false, approveReady: true },
],
submitResults: [],
getTabId: async () => null,
isTabAlive: async () => false,
queryTabsInAutomationWindow: async (queryInfo) => {
queries.push(queryInfo);
return [
{
id: 9,
active: true,
currentWindow: true,
url: 'https://www.paypal.com/pay/?token=BA-window',
},
];
},
tabUrls: [
'https://www.paypal.com/pay/?token=BA-window',
],
});
await executor.executePayPalApprove({
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
});
assert.deepEqual(queries, [{}]);
assert.deepEqual(events.updatedTabs, [{ tabId: 9, updateInfo: { active: true } }]);
assert.deepEqual(events.completed.map((item) => item.step), [8]);
});
test('PayPal approve auto-detects split email then password pages', async () => {
const { executor, events } = createExecutor({
pageStates: [
@@ -87,6 +87,9 @@ const console = {
events.push({ type: 'warn', args });
},
};
async function sendSidepanelMessage(message) {
return chrome.runtime.sendMessage(message);
}
async function persistCurrentSettingsForAction() {
events.push({ type: 'sync-settings' });
${persistImpl ? `return (${persistImpl})(events, {
+119
View File
@@ -0,0 +1,119 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
function createApi() {
const bundle = [
extractFunction('normalizeAutomationWindowId'),
extractFunction('getCurrentSidepanelWindowId'),
extractFunction('shouldAttachAutomationWindow'),
extractFunction('sendSidepanelMessage'),
].join('\n');
return new Function(`
let latestState = {};
const events = [];
const chrome = {
windows: {
async getCurrent() {
events.push({ type: 'get-current-window' });
return { id: 321 };
},
},
runtime: {
async sendMessage(message) {
events.push({ type: 'send', message });
return { ok: true };
},
},
};
const console = { warn() {} };
function syncLatestState(patch) {
latestState = { ...latestState, ...patch };
events.push({ type: 'sync', patch });
}
${bundle}
return {
sendSidepanelMessage,
shouldAttachAutomationWindow,
getEvents() {
return events;
},
getLatestState() {
return latestState;
},
};
`)();
}
test('sidepanel attaches the current window id when starting automation actions', async () => {
const api = createApi();
await api.sendSidepanelMessage({
type: 'AUTO_RUN',
source: 'sidepanel',
payload: { totalRuns: 1 },
});
const sent = api.getEvents().find((entry) => entry.type === 'send').message;
assert.equal(sent.payload.automationWindowId, 321);
assert.equal(api.getLatestState().automationWindowId, 321);
});
test('sidepanel leaves non-automation messages unchanged', async () => {
const api = createApi();
await api.sendSidepanelMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { emailPrefix: 'demo' },
});
const events = api.getEvents();
assert.equal(events.some((entry) => entry.type === 'get-current-window'), false);
assert.deepEqual(events.find((entry) => entry.type === 'send').message.payload, { emailPrefix: 'demo' });
});