feat: add configurable operation delay
This commit is contained in:
@@ -1,7 +1,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const { createAuthPageRecovery } = require('../content/auth-page-recovery.js');
|
||||
const source = fs.readFileSync('content/auth-page-recovery.js', 'utf8');
|
||||
|
||||
function extractFunction(sourceText, name) {
|
||||
const start = sourceText.indexOf(`function ${name}(`);
|
||||
assert.notEqual(start, -1, `missing ${name}`);
|
||||
const bodyStart = sourceText.indexOf('{', start);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < sourceText.length; i += 1) {
|
||||
if (sourceText[i] === '{') depth += 1;
|
||||
if (sourceText[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return sourceText.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unterminated ${name}`);
|
||||
}
|
||||
|
||||
function createRetryButton() {
|
||||
return {
|
||||
@@ -42,9 +59,13 @@ function createRecoveryApi(state) {
|
||||
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
|
||||
isVisibleElement: () => true,
|
||||
log: () => {},
|
||||
performOperationWithDelay: state.performOperationWithDelay,
|
||||
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/email-verification/i,
|
||||
simulateClick: () => {
|
||||
state.clickCount += 1;
|
||||
if (Array.isArray(state.events)) {
|
||||
state.events.push('click:retry');
|
||||
}
|
||||
if (typeof state.onClick === 'function') {
|
||||
state.onClick(state);
|
||||
return;
|
||||
@@ -54,6 +75,9 @@ function createRecoveryApi(state) {
|
||||
},
|
||||
sleep: async (ms = 0) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(1, Math.min(5, ms))));
|
||||
if (Array.isArray(state.events) && ms === 250) {
|
||||
state.events.push(`poll-sleep:${ms}`);
|
||||
}
|
||||
if (typeof state.onSleep === 'function') {
|
||||
state.onSleep(state);
|
||||
}
|
||||
@@ -163,6 +187,48 @@ test('auth page recovery clicks retry and waits until page recovers', async () =
|
||||
assert.equal(state.retryVisible, false);
|
||||
});
|
||||
|
||||
test('auth page recovery routes retry click through operation delay without wrapping polling sleeps', async () => {
|
||||
const authRetryEvents = [];
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
events: authRetryEvents,
|
||||
pageText: 'Something went wrong. Please try again.',
|
||||
retryVisible: true,
|
||||
onClick() {},
|
||||
onSleep(currentState) {
|
||||
currentState.retryVisible = false;
|
||||
currentState.pageText = 'Recovered login form';
|
||||
},
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
authRetryEvents.push(`operation:${metadata.label}:start`);
|
||||
const result = await operation();
|
||||
authRetryEvents.push(`operation:${metadata.label}:end`);
|
||||
authRetryEvents.push(`delay:${metadata.label}:2000`);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
await api.recoverAuthRetryPage({
|
||||
logLabel: '步骤 8:检测到重试页,正在点击“重试”恢复',
|
||||
pathPatterns: [/\/log-in(?:[/?#]|$)/i],
|
||||
step: 8,
|
||||
timeoutMs: 1000,
|
||||
waitAfterClickMs: 1000,
|
||||
pollIntervalMs: 250,
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(authRetryEvents, [
|
||||
'operation:auth-retry-click:start',
|
||||
'click:retry',
|
||||
'operation:auth-retry-click:end',
|
||||
'delay:auth-retry-click:2000',
|
||||
'poll-sleep:250',
|
||||
]);
|
||||
assert.equal(authRetryEvents.filter((event) => event.startsWith('delay:auth-retry-click')).length, 1);
|
||||
assert.doesNotMatch(extractFunction(source, 'waitForRetryPageRecoveryAfterClick'), /performOperationWithDelay\(/);
|
||||
});
|
||||
|
||||
test('auth page recovery can click retry twice before page recovers', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
|
||||
@@ -147,7 +147,7 @@ test('getMailConfig keeps provider metadata for 2925 mailboxes', () => {
|
||||
source: 'mail-2925',
|
||||
url: 'https://2925.com/#/mailList',
|
||||
label: '2925 邮箱',
|
||||
inject: ['content/utils.js', 'content/mail-2925.js'],
|
||||
inject: ['content/utils.js', 'content/operation-delay.js', 'content/mail-2925.js'],
|
||||
injectSource: 'mail-2925',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,3 +130,35 @@ test('SAVE_SETTING broadcasts free phone reuse setting updates for realtime side
|
||||
'expected SAVE_SETTING to broadcast free reuse switch updates'
|
||||
);
|
||||
});
|
||||
|
||||
test('SAVE_SETTING broadcasts operation delay setting without background success log', async () => {
|
||||
const source = fs.readFileSync('background/message-router.js', 'utf8');
|
||||
const globalScope = { console };
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMessageRouter;`)(globalScope);
|
||||
const broadcasts = [];
|
||||
const logs = [];
|
||||
let state = { operationDelayEnabled: true, plusModeEnabled: false, plusPaymentMethod: 'paypal' };
|
||||
|
||||
const router = api.createMessageRouter({
|
||||
addLog: async (message, level = 'info') => logs.push({ message, level }),
|
||||
buildLuckmailSessionSettingsPayload: () => ({}),
|
||||
buildPersistentSettingsPayload: (input = {}) => Object.prototype.hasOwnProperty.call(input, 'operationDelayEnabled')
|
||||
? { operationDelayEnabled: input.operationDelayEnabled === false ? false : true }
|
||||
: {},
|
||||
broadcastDataUpdate: (payload) => broadcasts.push(payload),
|
||||
getState: async () => ({ ...state }),
|
||||
setPersistentSettings: async () => {},
|
||||
setState: async (updates) => { state = { ...state, ...updates }; },
|
||||
});
|
||||
|
||||
const response = await router.handleMessage({
|
||||
type: 'SAVE_SETTING',
|
||||
source: 'sidepanel',
|
||||
payload: { operationDelayEnabled: false },
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.equal(state.operationDelayEnabled, false);
|
||||
assert.deepStrictEqual(broadcasts.at(-1), { operationDelayEnabled: false });
|
||||
assert.equal(logs.length, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
test('confirm-oauth and platform-verify stay free of operation delay gate calls', () => {
|
||||
for (const file of [
|
||||
'background/steps/confirm-oauth.js',
|
||||
'background/steps/platform-verify.js',
|
||||
'background/panel-bridge.js',
|
||||
'content/sub2api-panel.js',
|
||||
]) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
assert.doesNotMatch(source, /performOperationWithDelay\(/, `${file} must not call the operation delay gate`);
|
||||
assert.doesNotMatch(source, /content\/operation-delay\.js/, `${file} must not inject operation delay`);
|
||||
}
|
||||
});
|
||||
|
||||
test('operation delay gate names exactly the two excluded step keys', () => {
|
||||
const source = fs.readFileSync('content/operation-delay.js', 'utf8');
|
||||
assert.match(source, /confirm-oauth/);
|
||||
assert.match(source, /platform-verify/);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
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 start = source.indexOf(`function ${name}(`);
|
||||
assert.notEqual(start, -1, `missing ${name}`);
|
||||
let parenDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let bodyStart = -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) {
|
||||
bodyStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.notEqual(bodyStart, -1, `missing body for ${name}`);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < source.length; i += 1) {
|
||||
if (source[i] === '{') depth += 1;
|
||||
if (source[i] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unterminated ${name}`);
|
||||
}
|
||||
|
||||
test('operationDelayEnabled defaults to enabled and strictly normalizes values', () => {
|
||||
const defaultsBlock = source.slice(
|
||||
source.indexOf('const PERSISTED_SETTING_DEFAULTS = {'),
|
||||
source.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);')
|
||||
);
|
||||
assert.match(defaultsBlock, /operationDelayEnabled:\s*true/);
|
||||
|
||||
const api = new Function(`${extractFunction('normalizePersistentSettingValue')}; return { normalizePersistentSettingValue };`)();
|
||||
for (const value of [undefined, null, '', 0, 'false']) {
|
||||
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', value), true);
|
||||
}
|
||||
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', true), true);
|
||||
assert.equal(api.normalizePersistentSettingValue('operationDelayEnabled', false), false);
|
||||
});
|
||||
|
||||
test('operationDelayEnabled is normalized through the background settings payload path', () => {
|
||||
const api = new Function(`
|
||||
const PERSISTED_SETTING_DEFAULTS = { operationDelayEnabled: true };
|
||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
|
||||
${extractFunction('normalizePersistentSettingValue')}
|
||||
${extractFunction('buildPersistentSettingsPayload')}
|
||||
return { buildPersistentSettingsPayload };
|
||||
`)();
|
||||
|
||||
assert.equal(api.buildPersistentSettingsPayload({}, { fillDefaults: true }).operationDelayEnabled, true);
|
||||
for (const value of [undefined, null, '', 0, 'false', true]) {
|
||||
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: value }, { fillDefaults: true }).operationDelayEnabled, true);
|
||||
}
|
||||
assert.equal(api.buildPersistentSettingsPayload({ operationDelayEnabled: false }).operationDelayEnabled, false);
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function loadOperationDelayApi(overrides = {}) {
|
||||
const source = fs.readFileSync('content/operation-delay.js', 'utf8');
|
||||
const root = {
|
||||
console,
|
||||
sleep: async (ms) => overrides.events?.push(`sleep:${ms}`),
|
||||
chrome: overrides.chrome || { storage: { local: { get: async () => ({}) }, onChanged: { addListener() {} } } },
|
||||
};
|
||||
new Function('self', 'globalThis', `${source}; return self.CodexOperationDelay;`)(root, root);
|
||||
return root.CodexOperationDelay;
|
||||
}
|
||||
|
||||
async function resolveOrPending(promise, timeoutMs = 10) {
|
||||
return Promise.race([
|
||||
promise.then(() => 'resolved'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('pending'), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
function waitForTimer() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('operation delay runs action first and waits once after completion', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi({ events });
|
||||
await api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill' }, async () => {
|
||||
events.push('operation:start');
|
||||
events.push('operation:end');
|
||||
}, { sleep: async (ms) => events.push(`sleep:${ms}`), getEnabled: () => true });
|
||||
assert.deepStrictEqual(events, ['operation:start', 'operation:end', 'sleep:2000']);
|
||||
});
|
||||
|
||||
test('operation delay skips disabled mode and excluded step keys', async () => {
|
||||
const api = loadOperationDelayApi();
|
||||
assert.equal(api.shouldDelayOperation({ enabled: false, stepKey: 'fill-profile', kind: 'click' }), false);
|
||||
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'confirm-oauth', kind: 'click' }), false);
|
||||
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'platform-verify', kind: 'submit' }), false);
|
||||
assert.equal(api.shouldDelayOperation({ enabled: true, stepKey: 'fill-profile', kind: 'fill' }), true);
|
||||
});
|
||||
|
||||
test('operation delay defaults unresolved settings to enabled', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi({ events, chrome: { storage: { local: { get: async () => ({}) }, onChanged: { addListener() {} } } } });
|
||||
await api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'fill' }, async () => events.push('operation'), { sleep: async (ms) => events.push(`sleep:${ms}`) });
|
||||
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
|
||||
});
|
||||
|
||||
test('persisted disabled setting prevents first operation delay even before initial restore resolves', async () => {
|
||||
const events = [];
|
||||
let resolveStorage;
|
||||
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: { storage: { local: { get: async () => storageReady }, onChanged: { addListener() {} } } },
|
||||
});
|
||||
|
||||
const pendingOperation = api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
|
||||
resolveStorage({ operationDelayEnabled: false });
|
||||
await pendingOperation;
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
});
|
||||
|
||||
test('newer disabled storage change wins over older pending restore', async () => {
|
||||
const events = [];
|
||||
let changeListener = null;
|
||||
let resolveStorage;
|
||||
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: { get: async () => storageReady },
|
||||
onChanged: { addListener(listener) { changeListener = listener; } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
|
||||
assert.equal(api.getOperationDelayEnabled(), false);
|
||||
resolveStorage({ operationDelayEnabled: true });
|
||||
await waitForTimer();
|
||||
|
||||
assert.equal(api.getOperationDelayEnabled(), false);
|
||||
await api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
});
|
||||
|
||||
test('newer enabled storage change wins over older pending restore', async () => {
|
||||
const events = [];
|
||||
let changeListener = null;
|
||||
let resolveStorage;
|
||||
const storageReady = new Promise((resolve) => { resolveStorage = resolve; });
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: { get: async () => storageReady },
|
||||
onChanged: { addListener(listener) { changeListener = listener; } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
changeListener({ operationDelayEnabled: { newValue: true } }, 'local');
|
||||
assert.equal(api.getOperationDelayEnabled(), true);
|
||||
resolveStorage({ operationDelayEnabled: false });
|
||||
await waitForTimer();
|
||||
|
||||
assert.equal(api.getOperationDelayEnabled(), true);
|
||||
await api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
|
||||
});
|
||||
|
||||
test('first operation honors async persisted disabled setting before fallback budget expires', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: { get: async () => new Promise((resolve) => setTimeout(() => resolve({ operationDelayEnabled: false }), 5)) },
|
||||
onChanged: { addListener() {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
});
|
||||
|
||||
test('hanging initial storage restore falls back to enabled delay and returns', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: { storage: { local: { get: async () => new Promise(() => {}) }, onChanged: { addListener() {} } } },
|
||||
});
|
||||
|
||||
const operation = api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
|
||||
assert.equal(await resolveOrPending(operation, 100), 'resolved');
|
||||
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
|
||||
});
|
||||
|
||||
test('explicit disabled metadata does not wait for hanging initial storage restore', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: { storage: { local: { get: async () => new Promise(() => {}) }, onChanged: { addListener() {} } } },
|
||||
});
|
||||
|
||||
const operation = api.performOperationWithDelay(
|
||||
{ enabled: false, stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
|
||||
assert.equal(await resolveOrPending(operation), 'resolved');
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
});
|
||||
|
||||
test('invalid persisted values still resolve to enabled', async () => {
|
||||
const api = loadOperationDelayApi({ chrome: { storage: { local: { get: async () => ({ operationDelayEnabled: 'false' }) }, onChanged: { addListener() {} } } } });
|
||||
await api.refreshOperationDelaySetting();
|
||||
assert.equal(api.getOperationDelayEnabled(), true);
|
||||
});
|
||||
|
||||
test('shouldDelayOperation uses cached disabled setting by default', async () => {
|
||||
const api = loadOperationDelayApi({ chrome: { storage: { local: { get: async () => ({ operationDelayEnabled: false }) }, onChanged: { addListener() {} } } } });
|
||||
await api.refreshOperationDelaySetting();
|
||||
assert.equal(api.getOperationDelayEnabled(), false);
|
||||
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
|
||||
});
|
||||
|
||||
test('storage change to disabled is honored by later delayed operations', async () => {
|
||||
const events = [];
|
||||
let changeListener = null;
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: { get: async () => ({ operationDelayEnabled: true }) },
|
||||
onChanged: { addListener(listener) { changeListener = listener; } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.refreshOperationDelaySetting();
|
||||
changeListener({ operationDelayEnabled: { newValue: false } }, 'local');
|
||||
|
||||
assert.equal(api.getOperationDelayEnabled(), false);
|
||||
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), false);
|
||||
await api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
assert.deepStrictEqual(events, ['operation']);
|
||||
});
|
||||
|
||||
test('storage change to enabled is honored by later delayed operations', async () => {
|
||||
const events = [];
|
||||
let changeListener = null;
|
||||
const api = loadOperationDelayApi({
|
||||
events,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: { get: async () => ({ operationDelayEnabled: false }) },
|
||||
onChanged: { addListener(listener) { changeListener = listener; } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await api.refreshOperationDelaySetting();
|
||||
changeListener({ operationDelayEnabled: { newValue: true } }, 'local');
|
||||
|
||||
assert.equal(api.getOperationDelayEnabled(), true);
|
||||
assert.equal(api.shouldDelayOperation({ stepKey: 'fill-profile', kind: 'fill' }), true);
|
||||
await api.performOperationWithDelay(
|
||||
{ stepKey: 'fill-profile', kind: 'fill' },
|
||||
async () => events.push('operation'),
|
||||
{ sleep: async (ms) => events.push(`sleep:${ms}`) }
|
||||
);
|
||||
assert.deepStrictEqual(events, ['operation', 'sleep:2000']);
|
||||
});
|
||||
|
||||
test('operation delay uses stop-aware sleep and propagates stop errors', async () => {
|
||||
const api = loadOperationDelayApi();
|
||||
await assert.rejects(
|
||||
() => api.performOperationWithDelay({ stepKey: 'fill-profile', kind: 'click' }, async () => {}, {
|
||||
sleep: async () => { throw new Error('流程已被用户停止。'); },
|
||||
getEnabled: () => true,
|
||||
}),
|
||||
/流程已被用户停止/
|
||||
);
|
||||
});
|
||||
|
||||
test('grouped split-code metadata still delays only once', async () => {
|
||||
const events = [];
|
||||
const api = loadOperationDelayApi();
|
||||
await api.performOperationWithDelay({ stepKey: 'fetch-signup-code', kind: 'grouped-code' }, async () => {
|
||||
for (let index = 0; index < 6; index += 1) events.push(`fill:${index}`);
|
||||
}, { sleep: async (ms) => events.push(`sleep:${ms}`), getEnabled: () => true });
|
||||
assert.deepStrictEqual(events, ['fill:0', 'fill:1', 'fill:2', 'fill:3', 'fill:4', 'fill:5', 'sleep:2000']);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const source = fs.readFileSync('content/gopay-flow.js', 'utf8');
|
||||
|
||||
@@ -48,6 +49,200 @@ function extractFunction(name) {
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createGoPayContentHarness() {
|
||||
const gopayEvents = [];
|
||||
const attrs = new Map();
|
||||
let listener = null;
|
||||
let elements = [];
|
||||
let activeOperationLabel = null;
|
||||
const body = { innerText: 'Phone number: +86', textContent: 'Phone number: +86' };
|
||||
|
||||
function createElement({ tagName = 'DIV', text = '', type = '', id = '', name = '', placeholder = '', attrs: initialAttrs = {}, onClick = null } = {}) {
|
||||
const attrMap = new Map(Object.entries(initialAttrs));
|
||||
if (type) attrMap.set('type', type);
|
||||
if (id) attrMap.set('id', id);
|
||||
if (name) attrMap.set('name', name);
|
||||
if (placeholder) attrMap.set('placeholder', placeholder);
|
||||
return {
|
||||
nodeType: 1,
|
||||
tagName,
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
textContent: text,
|
||||
innerText: text,
|
||||
value: '',
|
||||
className: initialAttrs.class || '',
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
parentElement: null,
|
||||
style: { display: 'block', visibility: 'visible', opacity: '1' },
|
||||
getAttribute(key) {
|
||||
if (key === 'class') return this.className;
|
||||
if (key === 'type') return this.type || attrMap.get(key) || '';
|
||||
if (key === 'id') return this.id || attrMap.get(key) || '';
|
||||
if (key === 'name') return this.name || attrMap.get(key) || '';
|
||||
if (key === 'placeholder') return this.placeholder || attrMap.get(key) || '';
|
||||
return attrMap.has(key) ? attrMap.get(key) : null;
|
||||
},
|
||||
scrollIntoView() {},
|
||||
focus() {},
|
||||
dispatchEvent() { return true; },
|
||||
click() {
|
||||
if (typeof onClick === 'function') onClick(this);
|
||||
const field = this.getAttribute('data-test-field');
|
||||
if (field) {
|
||||
gopayEvents.push({ type: 'click', field, label: activeOperationLabel });
|
||||
}
|
||||
},
|
||||
getBoundingClientRect() { return { left: 20, top: 30, width: 180, height: 44 }; },
|
||||
};
|
||||
}
|
||||
|
||||
const context = {
|
||||
console: { log() {}, warn() {}, error() {}, info() {} },
|
||||
location: { href: 'https://gopay.co.id/linking' },
|
||||
window: {},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
body,
|
||||
documentElement: {
|
||||
getAttribute(name) { return attrs.get(name) || null; },
|
||||
setAttribute(name, value) { attrs.set(name, String(value)); },
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '.search-country') return null;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const text = String(selector || '');
|
||||
if (text.includes('country-item') || text.includes('[role="option"]') || text.includes('li')) {
|
||||
return elements.filter((element) => element.tagName === 'LI' || /country-item/i.test(element.className) || element.getAttribute('role') === 'option');
|
||||
}
|
||||
if (text.includes('button') || text.includes('[role="button"]') || text.includes('a')) return elements.filter((element) => element.tagName === 'BUTTON' || element.tagName === 'A');
|
||||
if (text.includes('input') || text.includes('textarea')) return elements.filter((element) => element.tagName === 'INPUT' || element.tagName === 'TEXTAREA');
|
||||
if (text.includes('li') || text.includes('[role="option"]') || text.includes('div') || text.includes('span')) return [];
|
||||
return [];
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: { addListener(fn) { listener = fn; } },
|
||||
},
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
gopayEvents.push({ type: 'operation', label: metadata.label, kind: metadata.kind });
|
||||
const previousOperationLabel = activeOperationLabel;
|
||||
activeOperationLabel = metadata.label;
|
||||
let result;
|
||||
try {
|
||||
result = await operation();
|
||||
} finally {
|
||||
activeOperationLabel = previousOperationLabel;
|
||||
}
|
||||
gopayEvents.push({ type: 'delay', label: metadata.label, ms: 2000 });
|
||||
return result;
|
||||
},
|
||||
},
|
||||
resetStopState() {},
|
||||
isStopError() { return false; },
|
||||
throwIfStopped() {},
|
||||
sleep() { return Promise.resolve(); },
|
||||
fillInput(element, value) {
|
||||
element.value = value;
|
||||
const field = element.getAttribute?.('data-test-field');
|
||||
if (field) {
|
||||
gopayEvents.push({ type: 'fill', field, label: activeOperationLabel, value });
|
||||
}
|
||||
},
|
||||
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
|
||||
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
|
||||
KeyboardEvent: class TestKeyboardEvent { constructor(type) { this.type = type; } },
|
||||
};
|
||||
context.window = context;
|
||||
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
context.window.screenX = 0;
|
||||
context.window.screenY = 0;
|
||||
context.window.innerWidth = 390;
|
||||
context.window.innerHeight = 844;
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context);
|
||||
assert.equal(typeof listener, 'function');
|
||||
|
||||
async function send(message) {
|
||||
return await new Promise((resolve) => {
|
||||
listener(message, {}, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
gopayEvents,
|
||||
send,
|
||||
showPhonePage() {
|
||||
body.innerText = 'Phone number: +86';
|
||||
body.textContent = body.innerText;
|
||||
elements = [
|
||||
createElement({ tagName: 'INPUT', type: 'tel', id: 'phone', name: 'gopay_phone', placeholder: 'GoPay phone' }),
|
||||
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-phone' }),
|
||||
];
|
||||
},
|
||||
showPhonePageWithCountryMismatch() {
|
||||
body.innerText = 'Phone number: +86';
|
||||
body.textContent = body.innerText;
|
||||
const countryToggle = createElement({ tagName: 'BUTTON', text: '+86', id: 'country-toggle', attrs: { class: 'phone-code-wrapper', 'data-test-field': 'country-toggle' } });
|
||||
const countrySearch = createElement({ tagName: 'INPUT', type: 'text', id: 'country-search', name: 'country_search', placeholder: 'Country code', attrs: { 'data-test-field': 'country-search' } });
|
||||
const countryOption = createElement({
|
||||
tagName: 'LI',
|
||||
text: 'Indonesia +62',
|
||||
id: 'country-option-id',
|
||||
attrs: { class: 'country-item', role: 'option', 'data-test-field': 'country-option' },
|
||||
onClick: () => {
|
||||
countryToggle.textContent = '+62';
|
||||
countryToggle.innerText = '+62';
|
||||
body.innerText = 'Phone number: +62';
|
||||
body.textContent = body.innerText;
|
||||
},
|
||||
});
|
||||
elements = [
|
||||
countryToggle,
|
||||
countrySearch,
|
||||
countryOption,
|
||||
createElement({ tagName: 'INPUT', type: 'tel', id: 'phone', name: 'gopay_phone', placeholder: 'GoPay phone' }),
|
||||
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-phone' }),
|
||||
];
|
||||
},
|
||||
showOtpPage() {
|
||||
body.innerText = 'Enter OTP code from WhatsApp';
|
||||
body.textContent = body.innerText;
|
||||
elements = [
|
||||
createElement({ tagName: 'INPUT', type: 'text', id: 'otp', name: 'otp', placeholder: 'OTP code' }),
|
||||
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-otp' }),
|
||||
];
|
||||
},
|
||||
showPinPage() {
|
||||
body.innerText = 'Enter PIN to continue';
|
||||
body.textContent = body.innerText;
|
||||
elements = [
|
||||
createElement({ tagName: 'INPUT', type: 'password', id: 'pin', name: 'pin', placeholder: 'PIN' }),
|
||||
createElement({ tagName: 'BUTTON', text: 'Continue', id: 'continue-pin' }),
|
||||
];
|
||||
},
|
||||
showContinuePage() {
|
||||
body.innerText = 'Link your GoPay account';
|
||||
body.textContent = body.innerText;
|
||||
elements = [createElement({ tagName: 'BUTTON', text: 'Hubungkan', id: 'continue' })];
|
||||
},
|
||||
showPayNowPage() {
|
||||
body.innerText = 'Confirm payment';
|
||||
body.textContent = body.innerText;
|
||||
elements = [createElement({ tagName: 'BUTTON', text: 'Pay now' })];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
|
||||
const bundle = [
|
||||
extractFunction('dispatchPointerMouseSequence'),
|
||||
@@ -85,6 +280,48 @@ return { humanClickElement };
|
||||
assert.equal(events.at(-2), 'native-click');
|
||||
});
|
||||
|
||||
test('GoPay content routes form submits and terminal clicks through the operation delay gate', async () => {
|
||||
const harness = createGoPayContentHarness();
|
||||
|
||||
harness.showPhonePage();
|
||||
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_PHONE', payload: { phone: '+8613800138000', countryCode: '+86' } })).ok, true);
|
||||
|
||||
harness.showOtpPage();
|
||||
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_OTP', payload: { otp: '123456' } })).ok, true);
|
||||
|
||||
harness.showPinPage();
|
||||
assert.equal((await harness.send({ type: 'GOPAY_SUBMIT_PIN', payload: { pin: '654321' } })).ok, true);
|
||||
|
||||
harness.showContinuePage();
|
||||
assert.equal((await harness.send({ type: 'GOPAY_CLICK_CONTINUE', payload: {} })).ok, true);
|
||||
|
||||
harness.showPayNowPage();
|
||||
assert.equal((await harness.send({ type: 'GOPAY_CLICK_PAY_NOW', payload: {} })).ok, true);
|
||||
|
||||
assert.deepStrictEqual(harness.gopayEvents.filter((event) => event.type === 'delay').map((event) => event.label), [
|
||||
'submit-phone',
|
||||
'submit-otp',
|
||||
'submit-pin',
|
||||
'click-continue',
|
||||
'click-pay-now',
|
||||
]);
|
||||
assert.equal(harness.gopayEvents.some((event) => event.type === 'delay' && event.ms !== 2000), false);
|
||||
});
|
||||
|
||||
test('GoPay country-code changes occur inside the submit-phone operation delay gate', async () => {
|
||||
const harness = createGoPayContentHarness();
|
||||
|
||||
harness.showPhonePageWithCountryMismatch();
|
||||
const result = await harness.send({ type: 'GOPAY_SUBMIT_PHONE', payload: { phone: '+6281234567890', countryCode: '+62' } });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.countryChanged, true);
|
||||
const countryEvents = harness.gopayEvents.filter((event) => ['country-toggle', 'country-search', 'country-option'].includes(event.field));
|
||||
assert.deepStrictEqual(countryEvents.map((event) => event.type), ['click', 'fill', 'click']);
|
||||
assert.deepStrictEqual(countryEvents.map((event) => event.label), ['submit-phone', 'submit-phone', 'submit-phone']);
|
||||
assert.deepStrictEqual(harness.gopayEvents.filter((event) => event.type === 'delay').map((event) => event.label), ['submit-phone']);
|
||||
});
|
||||
|
||||
|
||||
test('GoPay continue target exposes a debugger-clickable rect', () => {
|
||||
const bundle = [
|
||||
|
||||
@@ -954,16 +954,23 @@ const window = {
|
||||
},
|
||||
};
|
||||
|
||||
const operationDelayCalls = [];
|
||||
|
||||
async function sleep() {}
|
||||
function simulateClick(node) {
|
||||
node.click();
|
||||
}
|
||||
async function performOperationWithDelay(metadata, operation) {
|
||||
operationDelayCalls.push({ label: metadata.label, kind: metadata.kind });
|
||||
return await operation();
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
rememberCheckbox,
|
||||
agreementCheckbox,
|
||||
operationDelayCalls,
|
||||
ensureAgreementChecked,
|
||||
};
|
||||
`)();
|
||||
@@ -973,4 +980,8 @@ return {
|
||||
assert.equal(result, true);
|
||||
assert.equal(api.rememberCheckbox.checked, true);
|
||||
assert.equal(api.agreementCheckbox.checked, true);
|
||||
assert.deepStrictEqual(api.operationDelayCalls, [
|
||||
{ label: 'mail2925-agreement-checkbox', kind: 'click' },
|
||||
{ label: 'mail2925-agreement-checkbox', kind: 'click' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function extractPollEmailHandler(source) {
|
||||
const start = source.indexOf("message.type === 'POLL_EMAIL'");
|
||||
assert.notEqual(start, -1, 'missing POLL_EMAIL handler');
|
||||
const nextHandler = source.indexOf("message.type === '", start + 1);
|
||||
return nextHandler === -1 ? source.slice(start) : source.slice(start, nextHandler);
|
||||
}
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers.map((marker) => source.indexOf(marker)).find((index) => index >= 0);
|
||||
assert.notEqual(start, -1, `missing function ${name}`);
|
||||
let signatureDepth = 0;
|
||||
let signatureEnded = false;
|
||||
let bodyStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') signatureDepth += 1;
|
||||
if (ch === ')') {
|
||||
signatureDepth -= 1;
|
||||
if (signatureDepth === 0) signatureEnded = true;
|
||||
}
|
||||
if (ch === '{' && signatureEnded) {
|
||||
bodyStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.notEqual(bodyStart, -1, `missing body for ${name}`);
|
||||
let depth = 0;
|
||||
for (let i = bodyStart; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unterminated function ${name}`);
|
||||
}
|
||||
|
||||
test('mail polling handlers and cleanup handlers are not wrapped by operation delay', () => {
|
||||
const protectedFunctions = {
|
||||
'content/mail-163.js': ['handlePollEmail', 'returnToInbox', 'openMailAndGetMessageText', 'deleteEmail', 'refreshInbox'],
|
||||
'content/qq-mail.js': ['handlePollEmail', 'refreshInbox'],
|
||||
'content/icloud-mail.js': ['openMailItemAndRead', 'refreshInbox', 'handlePollEmail'],
|
||||
'content/mail-2925.js': ['handlePollEmail', 'openMailAndGetMessageText', 'deleteCurrentMailboxEmail', 'openMailAndDeleteAfterRead', 'deleteAllMailboxEmails', 'refreshInbox'],
|
||||
'content/gmail-mail.js': ['refreshInbox', 'openRowAndGetMessageText', 'handlePollEmail'],
|
||||
'content/inbucket-mail.js': ['refreshMailbox', 'openMailboxEntry', 'deleteCurrentMailboxMessage', 'handlePollEmail'],
|
||||
};
|
||||
|
||||
for (const [file, functionNames] of Object.entries(protectedFunctions)) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const pollHandler = extractPollEmailHandler(source);
|
||||
assert.doesNotMatch(pollHandler, /performOperationWithDelay\(/, `${file} POLL_EMAIL handler must stay delay-free`);
|
||||
|
||||
for (const functionName of functionNames) {
|
||||
const functionBody = extractFunction(source, functionName);
|
||||
assert.doesNotMatch(functionBody, /performOperationWithDelay\(/, `${file} ${functionName} must stay delay-free`);
|
||||
}
|
||||
}
|
||||
|
||||
const mail2925Source = fs.readFileSync('content/mail-2925.js', 'utf8');
|
||||
const deleteAllStart = mail2925Source.indexOf("message.type === 'DELETE_ALL_EMAILS'");
|
||||
assert.notEqual(deleteAllStart, -1, 'missing DELETE_ALL_EMAILS handler');
|
||||
const deleteAllBlock = mail2925Source.slice(deleteAllStart, mail2925Source.indexOf('return false;', deleteAllStart));
|
||||
assert.doesNotMatch(deleteAllBlock, /performOperationWithDelay\(/, '2925 cleanup must stay delay-free');
|
||||
});
|
||||
|
||||
test('mail polling bundles do not load operation delay module', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||
for (const file of ['content/mail-163.js', 'content/qq-mail.js', 'content/icloud-mail.js']) {
|
||||
const bundle = manifest.content_scripts.find((entry) => entry.js.includes(file))?.js || [];
|
||||
assert.equal(bundle.includes('content/operation-delay.js'), false, `${file} bundle must not include operation delay`);
|
||||
}
|
||||
});
|
||||
|
||||
test('WhatsApp code reader remains polling-only and delay-free', () => {
|
||||
const source = fs.readFileSync('content/whatsapp-flow.js', 'utf8');
|
||||
assert.doesNotMatch(source, /performOperationWithDelay\(/);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
for (const file of ['README.md', 'docs/使用教程/使用教程.md']) {
|
||||
test(`${file} documents operation delay`, () => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
assert.match(source, /操作间延迟/);
|
||||
assert.match(source, /默认开启/);
|
||||
assert.match(source, /2\s*秒/);
|
||||
assert.match(source, /分格|OTP|验证码/);
|
||||
assert.match(source, /邮箱|短信|轮询/);
|
||||
assert.match(source, /confirm-oauth|platform-verify/);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function assertOrdered(list, before, after) {
|
||||
assert.ok(list.includes(before), `missing ${before}`);
|
||||
assert.ok(list.includes(after), `missing ${after}`);
|
||||
assert.ok(list.indexOf(before) < list.indexOf(after), `${before} must load before ${after}`);
|
||||
}
|
||||
|
||||
test('manifest loads operation delay after utils only for covered auth/provider bundles', () => {
|
||||
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||
const authBundle = manifest.content_scripts.find((entry) => entry.js.includes('content/signup-page.js')).js;
|
||||
assertOrdered(authBundle, 'content/utils.js', 'content/operation-delay.js');
|
||||
assertOrdered(authBundle, 'content/operation-delay.js', 'content/auth-page-recovery.js');
|
||||
assertOrdered(authBundle, 'content/operation-delay.js', 'content/signup-page.js');
|
||||
|
||||
const duckBundle = manifest.content_scripts.find((entry) => entry.js.includes('content/duck-mail.js')).js;
|
||||
assertOrdered(duckBundle, 'content/utils.js', 'content/operation-delay.js');
|
||||
assertOrdered(duckBundle, 'content/operation-delay.js', 'content/duck-mail.js');
|
||||
|
||||
for (const pollingFile of ['content/qq-mail.js', 'content/mail-163.js', 'content/icloud-mail.js']) {
|
||||
const bundle = manifest.content_scripts.find((entry) => entry.js.includes(pollingFile))?.js || [];
|
||||
assert.equal(bundle.includes('content/operation-delay.js'), false, `${pollingFile} polling bundle must not load operation delay`);
|
||||
}
|
||||
});
|
||||
|
||||
test('dynamic covered injections load operation delay after utils', () => {
|
||||
const expectations = [
|
||||
['background.js', 'SIGNUP_PAGE_INJECT_FILES'],
|
||||
['background/steps/create-plus-checkout.js', 'PLUS_CHECKOUT_INJECT_FILES'],
|
||||
['background/steps/fill-plus-checkout.js', 'PLUS_CHECKOUT_INJECT_FILES'],
|
||||
['background/steps/paypal-approve.js', 'PAYPAL_INJECT_FILES'],
|
||||
['background/steps/gopay-approve.js', 'GOPAY_INJECT_FILES'],
|
||||
['background/mail-2925-session.js', 'MAIL2925_INJECT'],
|
||||
];
|
||||
for (const [file, constantName] of expectations) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const match = source.match(new RegExp(`const\\s+${constantName}\\s*=\\s*\\[([^\\]]+)\\]`));
|
||||
assert.ok(match, `missing ${constantName} in ${file}`);
|
||||
const block = match[1];
|
||||
assert.match(block, /'content\/utils\.js'[\s\S]*'content\/operation-delay\.js'/, `${file} must inject operation delay after utils`);
|
||||
if (constantName === 'SIGNUP_PAGE_INJECT_FILES') {
|
||||
assert.match(block, /'content\/operation-delay\.js'[\s\S]*'content\/auth-page-recovery\.js'/, 'auth recovery must load after operation delay');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('2925 provider reuse path also injects operation delay', () => {
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
const start = source.indexOf("if (provider === '2925')");
|
||||
assert.notEqual(start, -1, 'missing 2925 provider config');
|
||||
const end = source.indexOf("return { source: 'qq-mail'", start);
|
||||
const block = source.slice(start, end);
|
||||
assert.match(block, /inject:\s*\[[\s\S]*'content\/utils\.js'[\s\S]*'content\/operation-delay\.js'[\s\S]*'content\/mail-2925\.js'[\s\S]*\]/);
|
||||
});
|
||||
|
||||
test('excluded platform verification paths do not load operation delay', () => {
|
||||
for (const file of ['background/steps/platform-verify.js', 'background/panel-bridge.js']) {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
assert.doesNotMatch(source, /content\/operation-delay\.js/);
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const source = fs.readFileSync('background/steps/paypal-approve.js', 'utf8');
|
||||
const paypalContentSource = fs.readFileSync('content/paypal-flow.js', 'utf8');
|
||||
|
||||
function loadModule() {
|
||||
const self = {};
|
||||
@@ -85,6 +87,158 @@ function createExecutor({
|
||||
return { executor, events };
|
||||
}
|
||||
|
||||
function createPayPalContentHarness() {
|
||||
const paypalEvents = [];
|
||||
const attrs = new Map();
|
||||
let listener = null;
|
||||
let elements = [];
|
||||
const body = { innerText: '', textContent: '' };
|
||||
|
||||
function createElement({ tagName = 'DIV', text = '', type = '', id = '', name = '', placeholder = '' } = {}) {
|
||||
return {
|
||||
nodeType: 1,
|
||||
tagName,
|
||||
type,
|
||||
id,
|
||||
name,
|
||||
placeholder,
|
||||
textContent: text,
|
||||
value: '',
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
parentElement: null,
|
||||
style: { display: 'block', visibility: 'visible', opacity: '1' },
|
||||
getAttribute(key) {
|
||||
if (key === 'type') return this.type;
|
||||
if (key === 'id') return this.id;
|
||||
if (key === 'name') return this.name;
|
||||
if (key === 'placeholder') return this.placeholder;
|
||||
return null;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { left: 10, top: 10, width: 180, height: 44 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const emailInput = createElement({ tagName: 'INPUT', type: 'email', id: 'email', name: 'login_email', placeholder: 'Email' });
|
||||
const nextButton = createElement({ tagName: 'BUTTON', text: 'Next', id: 'btnNext' });
|
||||
const passwordInput = createElement({ tagName: 'INPUT', type: 'password', id: 'password', name: 'login_password', placeholder: 'Password' });
|
||||
const loginButton = createElement({ tagName: 'BUTTON', text: 'Log In', id: 'btnLogin' });
|
||||
const promptButton = createElement({ tagName: 'BUTTON', text: 'Not now', id: 'notNow' });
|
||||
const approveButton = createElement({ tagName: 'BUTTON', text: 'Agree and Continue', id: 'approve' });
|
||||
elements = [emailInput, nextButton];
|
||||
|
||||
const context = {
|
||||
console: { log() {}, warn() {}, error() {}, info() {} },
|
||||
location: { href: 'https://www.paypal.com/signin' },
|
||||
window: {},
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
body,
|
||||
documentElement: {
|
||||
getAttribute(name) {
|
||||
return attrs.get(name) || null;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
attrs.set(name, String(value));
|
||||
},
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input') return elements.filter((element) => element.tagName === 'INPUT');
|
||||
if (selector === 'input[type="email"]') return elements.filter((element) => element.type === 'email');
|
||||
if (selector === 'input[type="password"]') return elements.filter((element) => element.type === 'password');
|
||||
if (String(selector || '').includes('button') || String(selector || '').includes('[role="button"]')) {
|
||||
return elements.filter((element) => element.tagName === 'BUTTON');
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(fn) {
|
||||
listener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
paypalEvents.push(`operation:${metadata.label}:start`);
|
||||
const result = await operation();
|
||||
paypalEvents.push(`operation:${metadata.label}:end`);
|
||||
paypalEvents.push(`delay:${metadata.label}:2000`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
resetStopState() {},
|
||||
isStopError() { return false; },
|
||||
throwIfStopped() {},
|
||||
sleep() { return Promise.resolve(); },
|
||||
fillInput(element, value) {
|
||||
element.value = value;
|
||||
if (element === emailInput && value) {
|
||||
paypalEvents.push('fill:paypal-email');
|
||||
} else if (element === passwordInput && value) {
|
||||
paypalEvents.push('fill:paypal-password');
|
||||
}
|
||||
},
|
||||
simulateClick(element) {
|
||||
if (element === approveButton) {
|
||||
paypalEvents.push('click:paypal-approve');
|
||||
} else if (element === loginButton) {
|
||||
paypalEvents.push('click:paypal-password');
|
||||
} else if (element === promptButton) {
|
||||
paypalEvents.push('click:paypal-dismiss-prompt');
|
||||
}
|
||||
},
|
||||
};
|
||||
context.window = context;
|
||||
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible', opacity: '1' };
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(paypalContentSource, context);
|
||||
assert.equal(typeof listener, 'function');
|
||||
|
||||
async function send(message) {
|
||||
return await new Promise((resolve) => {
|
||||
listener(message, {}, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function showApprovalPage() {
|
||||
context.location.href = 'https://www.paypal.com/checkoutnow/approve';
|
||||
body.innerText = '';
|
||||
body.textContent = '';
|
||||
elements = [approveButton];
|
||||
}
|
||||
|
||||
function showPasswordPage() {
|
||||
context.location.href = 'https://www.paypal.com/signin';
|
||||
body.innerText = '';
|
||||
body.textContent = '';
|
||||
elements = [passwordInput, loginButton];
|
||||
}
|
||||
|
||||
function showCombinedLoginPageWithPrefilledEmail(email) {
|
||||
context.location.href = 'https://www.paypal.com/signin';
|
||||
body.innerText = '';
|
||||
body.textContent = '';
|
||||
emailInput.value = email;
|
||||
elements = [emailInput, passwordInput, loginButton];
|
||||
}
|
||||
|
||||
function showPasskeyPrompt() {
|
||||
context.location.href = 'https://www.paypal.com/signin';
|
||||
body.innerText = 'Save a passkey for faster login';
|
||||
body.textContent = body.innerText;
|
||||
elements = [promptButton];
|
||||
}
|
||||
|
||||
return { paypalEvents, send, showApprovalPage, showCombinedLoginPageWithPrefilledEmail, showPasswordPage, showPasskeyPrompt };
|
||||
}
|
||||
|
||||
test('PayPal approve keeps original combined email and password login path', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
@@ -107,6 +261,93 @@ test('PayPal approve keeps original combined email and password login path', asy
|
||||
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
|
||||
});
|
||||
|
||||
test('PayPal content routes email and approve page operations through the operation delay gate', async () => {
|
||||
const { paypalEvents, send, showApprovalPage } = createPayPalContentHarness();
|
||||
|
||||
const loginResult = await send({
|
||||
type: 'PAYPAL_SUBMIT_LOGIN',
|
||||
source: 'test',
|
||||
payload: { email: 'user@example.com', password: 'secret' },
|
||||
});
|
||||
assert.equal(loginResult.ok, true);
|
||||
assert.equal(loginResult.phase, 'email_submitted');
|
||||
|
||||
showApprovalPage();
|
||||
const approveResult = await send({ type: 'PAYPAL_CLICK_APPROVE', source: 'test', payload: {} });
|
||||
assert.equal(approveResult.ok, true);
|
||||
assert.equal(approveResult.clicked, true);
|
||||
|
||||
assert.deepStrictEqual(paypalEvents, [
|
||||
'operation:paypal-email:start',
|
||||
'fill:paypal-email',
|
||||
'operation:paypal-email:end',
|
||||
'delay:paypal-email:2000',
|
||||
'operation:paypal-approve:start',
|
||||
'click:paypal-approve',
|
||||
'operation:paypal-approve:end',
|
||||
'delay:paypal-approve:2000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('PayPal content routes password submit and prompt dismissal through the operation delay gate', async () => {
|
||||
const { paypalEvents, send, showPasswordPage, showPasskeyPrompt } = createPayPalContentHarness();
|
||||
|
||||
showPasswordPage();
|
||||
const passwordResult = await send({
|
||||
type: 'PAYPAL_SUBMIT_LOGIN',
|
||||
source: 'test',
|
||||
payload: { email: 'user@example.com', password: 'secret' },
|
||||
});
|
||||
assert.equal(passwordResult.ok, true);
|
||||
assert.equal(passwordResult.phase, 'password_submitted');
|
||||
|
||||
assert.deepStrictEqual(paypalEvents, [
|
||||
'operation:paypal-password:start',
|
||||
'fill:paypal-password',
|
||||
'click:paypal-password',
|
||||
'operation:paypal-password:end',
|
||||
'delay:paypal-password:2000',
|
||||
]);
|
||||
|
||||
paypalEvents.length = 0;
|
||||
showPasskeyPrompt();
|
||||
const promptResult = await send({ type: 'PAYPAL_DISMISS_PROMPTS', source: 'test', payload: {} });
|
||||
assert.equal(promptResult.ok, true);
|
||||
assert.equal(promptResult.clicked, 1);
|
||||
|
||||
assert.deepStrictEqual(paypalEvents, [
|
||||
'operation:paypal-dismiss-prompt:start',
|
||||
'click:paypal-dismiss-prompt',
|
||||
'operation:paypal-dismiss-prompt:end',
|
||||
'delay:paypal-dismiss-prompt:2000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('PayPal content keeps prefilled email values containing pass on the paypal-email path', async () => {
|
||||
const { paypalEvents, send, showCombinedLoginPageWithPrefilledEmail } = createPayPalContentHarness();
|
||||
|
||||
showCombinedLoginPageWithPrefilledEmail('compass@example.com');
|
||||
const loginResult = await send({
|
||||
type: 'PAYPAL_SUBMIT_LOGIN',
|
||||
source: 'test',
|
||||
payload: { email: 'compass@example.com', password: 'secret' },
|
||||
});
|
||||
|
||||
assert.equal(loginResult.ok, true);
|
||||
assert.equal(loginResult.phase, 'password_submitted');
|
||||
assert.deepStrictEqual(paypalEvents, [
|
||||
'operation:paypal-email:start',
|
||||
'fill:paypal-email',
|
||||
'operation:paypal-email:end',
|
||||
'delay:paypal-email:2000',
|
||||
'operation:paypal-password:start',
|
||||
'fill:paypal-password',
|
||||
'click:paypal-password',
|
||||
'operation:paypal-password:end',
|
||||
'delay:paypal-password:2000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('PayPal approve prefers the selected paypal pool account over legacy fields', async () => {
|
||||
const { executor, events } = createExecutor({
|
||||
pageStates: [
|
||||
|
||||
@@ -1,11 +1,159 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const source = fs.readFileSync('background/steps/create-plus-checkout.js', 'utf8');
|
||||
const plusCheckoutSource = fs.readFileSync('content/plus-checkout.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPlusCheckoutCreate;`)(globalScope);
|
||||
|
||||
function createCheckoutContentHarness() {
|
||||
const checkoutEvents = [];
|
||||
const attrs = new Map();
|
||||
let listener = null;
|
||||
const elements = [];
|
||||
|
||||
function createElement({ tagName = 'DIV', text = '', attrs: initialAttrs = {}, id = '', type = '', value = '' } = {}) {
|
||||
const attrMap = new Map(Object.entries(initialAttrs));
|
||||
if (id) attrMap.set('id', id);
|
||||
if (type) attrMap.set('type', type);
|
||||
const element = {
|
||||
nodeType: 1,
|
||||
tagName,
|
||||
id,
|
||||
type,
|
||||
value,
|
||||
textContent: text,
|
||||
innerText: text,
|
||||
className: initialAttrs.class || '',
|
||||
checked: initialAttrs.checked === 'true',
|
||||
disabled: false,
|
||||
hidden: false,
|
||||
dataset: {},
|
||||
children: [],
|
||||
parentElement: null,
|
||||
style: { display: 'block', visibility: 'visible' },
|
||||
getAttribute(name) {
|
||||
if (name === 'class') return this.className;
|
||||
if (name === 'id') return this.id || attrMap.get(name) || '';
|
||||
if (name === 'type') return this.type || attrMap.get(name) || '';
|
||||
return attrMap.has(name) ? attrMap.get(name) : '';
|
||||
},
|
||||
setAttribute(name, nextValue) {
|
||||
attrMap.set(name, String(nextValue));
|
||||
},
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
scrollIntoView() {},
|
||||
focus() {},
|
||||
dispatchEvent() {
|
||||
return true;
|
||||
},
|
||||
click() {},
|
||||
getBoundingClientRect() {
|
||||
return { left: 10, top: 20, width: 180, height: 44 };
|
||||
},
|
||||
};
|
||||
return element;
|
||||
}
|
||||
|
||||
const paymentButton = createElement({ tagName: 'BUTTON', text: 'PayPal', attrs: { role: 'tab', 'aria-selected': '' } });
|
||||
const fullNameInput = createElement({ tagName: 'INPUT', id: 'name', type: 'text', attrs: { name: 'billingName', placeholder: 'Full name' } });
|
||||
const addressInput = createElement({ tagName: 'INPUT', id: 'address', type: 'text', attrs: { name: 'addressLine1', placeholder: 'Address line 1' } });
|
||||
const cityInput = createElement({ tagName: 'INPUT', id: 'city', type: 'text', attrs: { name: 'locality', placeholder: 'City' } });
|
||||
const postalInput = createElement({ tagName: 'INPUT', id: 'postal', type: 'text', attrs: { name: 'postalCode', placeholder: 'Postal code' } });
|
||||
const suggestionOption = createElement({ tagName: 'LI', text: 'Unter den Linden 1, Berlin', attrs: { role: 'option', class: 'pac-item' } });
|
||||
const subscribeButton = createElement({ tagName: 'BUTTON', text: 'Subscribe', attrs: { type: 'submit', 'aria-label': 'Subscribe' } });
|
||||
subscribeButton.type = 'submit';
|
||||
elements.push(paymentButton, fullNameInput, addressInput, cityInput, postalInput, suggestionOption, subscribeButton);
|
||||
|
||||
const context = {
|
||||
console: { log() {}, warn() {}, error() {}, info() {} },
|
||||
location: { href: 'https://chatgpt.com/checkout/openai_ie/cs_test' },
|
||||
window: {},
|
||||
CSS: { escape: (value) => String(value) },
|
||||
Event: class TestEvent { constructor(type) { this.type = type; } },
|
||||
MouseEvent: class TestMouseEvent { constructor(type) { this.type = type; } },
|
||||
PointerEvent: class TestPointerEvent { constructor(type) { this.type = type; } },
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
body: {},
|
||||
documentElement: {
|
||||
getAttribute(name) {
|
||||
return attrs.get(name) || null;
|
||||
},
|
||||
setAttribute(name, nextValue) {
|
||||
attrs.set(name, String(nextValue));
|
||||
},
|
||||
},
|
||||
getElementById() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const text = String(selector || '');
|
||||
if (text.includes('label[for=')) return [];
|
||||
if (text.includes('[role="option"]') || text.includes('.pac-item') || text === 'li') return [suggestionOption];
|
||||
if (text === 'input, textarea') return elements.filter((element) => element.tagName === 'INPUT');
|
||||
if (text.includes('button[type="submit"]')) return [subscribeButton];
|
||||
if (text.includes('button') || text.includes('[role=') || text.includes('[tabindex]') || text.includes('[data-testid]')) {
|
||||
return elements.filter((element) => element.tagName === 'BUTTON');
|
||||
}
|
||||
if (text.includes('select') || text.includes('[aria-haspopup="listbox"]')) return [];
|
||||
return [];
|
||||
},
|
||||
},
|
||||
chrome: {
|
||||
runtime: {
|
||||
onMessage: {
|
||||
addListener(fn) {
|
||||
listener = fn;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
checkoutEvents.push({ type: 'operation', label: metadata.label, kind: metadata.kind });
|
||||
const result = await operation();
|
||||
checkoutEvents.push({ type: 'delay', label: metadata.label, ms: 2000 });
|
||||
return result;
|
||||
},
|
||||
},
|
||||
resetStopState() {},
|
||||
isStopError() { return false; },
|
||||
throwIfStopped() {},
|
||||
sleep() { return Promise.resolve(); },
|
||||
log() {},
|
||||
fillInput(element, nextValue) {
|
||||
element.value = nextValue;
|
||||
},
|
||||
simulateClick(element) {
|
||||
if (element === paymentButton) {
|
||||
paymentButton.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
},
|
||||
};
|
||||
context.window = context;
|
||||
context.window.getComputedStyle = (element) => element?.style || { display: 'block', visibility: 'visible' };
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(plusCheckoutSource, context);
|
||||
assert.equal(typeof listener, 'function');
|
||||
|
||||
async function send(message) {
|
||||
return await new Promise((resolve) => {
|
||||
listener(message, {}, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
return { checkoutEvents, send };
|
||||
}
|
||||
|
||||
test('Plus checkout create does not wait 20 seconds after opening checkout page', async () => {
|
||||
const events = [];
|
||||
const executor = api.createPlusCheckoutCreateExecutor({
|
||||
@@ -107,6 +255,74 @@ test('GoPay plus checkout create forwards gopay payment method to the checkout c
|
||||
assert.deepStrictEqual(events[0]?.payload, { paymentMethod: 'gopay' });
|
||||
});
|
||||
|
||||
test('Plus checkout content routes billing operations through the operation delay gate', async () => {
|
||||
const { checkoutEvents, send } = createCheckoutContentHarness();
|
||||
|
||||
const result = await send({
|
||||
type: 'FILL_PLUS_BILLING_AND_SUBMIT',
|
||||
source: 'test',
|
||||
payload: {
|
||||
fullName: 'Ada Lovelace',
|
||||
addressSeed: {
|
||||
skipAutocomplete: true,
|
||||
fallback: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
region: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'operation').map((event) => event.label), [
|
||||
'select-payment-method',
|
||||
'fill-billing-address',
|
||||
'click-subscribe',
|
||||
]);
|
||||
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'delay').map((event) => event.ms), [2000, 2000, 2000]);
|
||||
});
|
||||
|
||||
test('Plus checkout content routes same-frame autocomplete query and suggestion through separate operation delays', async () => {
|
||||
const { checkoutEvents, send } = createCheckoutContentHarness();
|
||||
|
||||
const result = await send({
|
||||
type: 'FILL_PLUS_BILLING_AND_SUBMIT',
|
||||
source: 'test',
|
||||
payload: {
|
||||
fullName: 'Ada Lovelace',
|
||||
addressSeed: {
|
||||
query: 'Unter den Linden',
|
||||
suggestionIndex: 0,
|
||||
fallback: {
|
||||
address1: 'Unter den Linden',
|
||||
city: 'Berlin',
|
||||
region: 'Berlin',
|
||||
postalCode: '10117',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'operation').map((event) => event.label), [
|
||||
'select-payment-method',
|
||||
'fill-address-query',
|
||||
'select-address-suggestion',
|
||||
'fill-billing-address',
|
||||
'click-subscribe',
|
||||
]);
|
||||
assert.deepStrictEqual(checkoutEvents.filter((event) => event.type === 'delay').map((event) => event.label), [
|
||||
'select-payment-method',
|
||||
'fill-address-query',
|
||||
'select-address-suggestion',
|
||||
'fill-billing-address',
|
||||
'click-subscribe',
|
||||
]);
|
||||
assert.equal(checkoutEvents.some((event) => event.type === 'delay' && event.ms !== 2000), false);
|
||||
});
|
||||
|
||||
test('GPC checkout injects Plus script before reading ChatGPT session token and sends X-API-Key', async () => {
|
||||
const events = [];
|
||||
const fetchCalls = [];
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
test('Duck address generation routes the generator click through operation delay', () => {
|
||||
const source = fs.readFileSync('content/duck-mail.js', 'utf8');
|
||||
assert.match(source, /performOperationWithDelay\([\s\S]*duck-generate-address/);
|
||||
});
|
||||
|
||||
test('2925 session preparation routes through operation delay while cleanup stays delay-free', () => {
|
||||
const source = fs.readFileSync('content/mail-2925.js', 'utf8');
|
||||
assert.match(source, /ENSURE_MAIL2925_SESSION[\s\S]*performOperationWithDelay/);
|
||||
const deleteAllStart = source.indexOf("message.type === 'DELETE_ALL_EMAILS'");
|
||||
assert.notEqual(deleteAllStart, -1, 'missing DELETE_ALL_EMAILS handler');
|
||||
const deleteAllBlock = source.slice(deleteAllStart, source.indexOf('return false;', deleteAllStart));
|
||||
assert.doesNotMatch(deleteAllBlock, /performOperationWithDelay\(/);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
let start = source.indexOf(`async function ${name}(`);
|
||||
if (start === -1) {
|
||||
start = source.indexOf(`function ${name}(`);
|
||||
}
|
||||
assert.notEqual(start, -1, `missing ${name}`);
|
||||
let depth = 0;
|
||||
let signatureEnded = false;
|
||||
let bodyStart = -1;
|
||||
for (let i = start; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '(') depth += 1;
|
||||
if (ch === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) signatureEnded = true;
|
||||
}
|
||||
if (ch === '{' && signatureEnded) {
|
||||
bodyStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let braceDepth = 0;
|
||||
for (let i = bodyStart; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '{') braceDepth += 1;
|
||||
if (ch === '}') {
|
||||
braceDepth -= 1;
|
||||
if (braceDepth === 0) return source.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`unterminated ${name}`);
|
||||
}
|
||||
|
||||
test('sidepanel exposes one operation delay switch in the existing settings surface', () => {
|
||||
assert.match(html, /id="row-operation-delay-settings"/);
|
||||
assert.match(html, /id="input-operation-delay-enabled"/);
|
||||
assert.match(html, /操作间延迟/);
|
||||
assert.match(html, /2\s*秒/);
|
||||
assert.match(html, /id="row-operation-delay-settings"[\s\S]{0,900}输入[\s\S]{0,900}选择[\s\S]{0,900}点击[\s\S]{0,900}提交[\s\S]{0,900}继续[\s\S]{0,900}授权/);
|
||||
});
|
||||
|
||||
test('sidepanel owns operation delay toggle feedback exactly once', () => {
|
||||
assert.match(source, /inputOperationDelayEnabled/);
|
||||
assert.match(source, /operationDelayEnabled/);
|
||||
assert.match(source, /appendLog\(\{[\s\S]*操作间延迟/);
|
||||
assert.doesNotMatch(source, /operationDelayEnabled[\s\S]{0,240}addLog\(/);
|
||||
});
|
||||
|
||||
test('sidepanel operation delay restore and save failure contracts are explicit', () => {
|
||||
assert.match(source, /normalizeOperationDelayEnabled/);
|
||||
assert.match(source, /lastConfirmedOperationDelayEnabled/);
|
||||
assert.match(source, /操作间延迟设置读取失败/);
|
||||
assert.match(source, /操作间延迟设置保存失败/);
|
||||
assert.match(source, /inputOperationDelayEnabled\.checked\s*=\s*lastConfirmedOperationDelayEnabled/);
|
||||
assert.match(source, /typeof\s+applyOperationDelayState\s*===\s*['"]function['"]/);
|
||||
});
|
||||
|
||||
test('sidepanel syncs operation delay DATA_UPDATED without becoming the success-log owner', () => {
|
||||
assert.match(source, /message\.payload\.operationDelayEnabled\s*!==\s*undefined[\s\S]{0,240}applyOperationDelayState\(message\.payload\)/);
|
||||
});
|
||||
|
||||
function loadOperationDelaySidepanelHarness() {
|
||||
const runtimeMessages = [];
|
||||
const logs = [];
|
||||
const inputOperationDelayEnabled = { checked: true, disabled: false };
|
||||
|
||||
const chrome = {
|
||||
runtime: {
|
||||
sendMessage: async (message) => {
|
||||
runtimeMessages.push(message);
|
||||
return { ok: true, state: { operationDelayEnabled: message.payload.operationDelayEnabled } };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const harness = new Function('chrome', 'appendLog', 'inputOperationDelayEnabled', 'initialLatestState', `
|
||||
let latestState = initialLatestState;
|
||||
let lastConfirmedOperationDelayEnabled = true;
|
||||
function syncLatestState(nextState) {
|
||||
latestState = { ...(latestState || {}), ...(nextState || {}) };
|
||||
}
|
||||
${extractFunction('normalizeOperationDelayEnabled')}
|
||||
${extractFunction('appendOperationDelayLog')}
|
||||
${extractFunction('applyOperationDelayState')}
|
||||
${extractFunction('persistOperationDelayToggle')}
|
||||
return {
|
||||
applyOperationDelayState,
|
||||
persistOperationDelayToggle,
|
||||
getLatestState: () => latestState,
|
||||
getLastConfirmedOperationDelayEnabled: () => lastConfirmedOperationDelayEnabled,
|
||||
setLastConfirmedOperationDelayEnabled: (value) => { lastConfirmedOperationDelayEnabled = value; },
|
||||
};
|
||||
`)(chrome, (entry) => logs.push(entry), inputOperationDelayEnabled, { operationDelayEnabled: true });
|
||||
|
||||
return { chrome, harness, inputOperationDelayEnabled, logs, runtimeMessages };
|
||||
}
|
||||
|
||||
test('operation delay switch logs once, defaults restore failures to enabled, and rolls back save failures', async () => {
|
||||
const { chrome, harness: helpers, inputOperationDelayEnabled: input, logs, runtimeMessages: sentMessages } = loadOperationDelaySidepanelHarness();
|
||||
|
||||
helpers.applyOperationDelayState({ operationDelayEnabled: false });
|
||||
assert.equal(input.checked, false);
|
||||
assert.equal(helpers.getLatestState().operationDelayEnabled, false);
|
||||
|
||||
helpers.applyOperationDelayState({ operationDelayEnabled: undefined }, { restoreFailed: true });
|
||||
assert.equal(input.checked, true);
|
||||
assert.equal(helpers.getLatestState().operationDelayEnabled, true);
|
||||
assert.equal(logs.filter((entry) => entry.level === 'warn').length, 1);
|
||||
|
||||
logs.length = 0;
|
||||
input.checked = false;
|
||||
await helpers.persistOperationDelayToggle();
|
||||
assert.equal(sentMessages.at(-1).payload.operationDelayEnabled, false);
|
||||
assert.equal(logs.length, 1);
|
||||
assert.match(logs[0].message, /关闭|off/i);
|
||||
|
||||
sentMessages.length = 0;
|
||||
chrome.runtime.sendMessage = async () => { throw new Error('network down'); };
|
||||
logs.length = 0;
|
||||
input.checked = true;
|
||||
helpers.setLastConfirmedOperationDelayEnabled(false);
|
||||
await assert.rejects(() => helpers.persistOperationDelayToggle(), /network down/);
|
||||
assert.equal(input.checked, false);
|
||||
assert.equal(logs.filter((entry) => entry.level === 'error').length, 1);
|
||||
});
|
||||
@@ -57,6 +57,9 @@ const logs = [];
|
||||
const completions = [];
|
||||
const clicks = [];
|
||||
const scheduled = [];
|
||||
const events = [];
|
||||
let releaseSubmitDelay;
|
||||
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
|
||||
|
||||
const snapshot = {
|
||||
state: 'password_page',
|
||||
@@ -66,10 +69,23 @@ const snapshot = {
|
||||
};
|
||||
|
||||
const window = {
|
||||
setTimeout(fn) {
|
||||
scheduled.push(fn);
|
||||
setTimeout(fn, ms) {
|
||||
scheduled.push({ fn, ms });
|
||||
return scheduled.length;
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
events.push('operation:' + metadata.label + ':start');
|
||||
const result = await operation();
|
||||
events.push('operation:' + metadata.label + ':end');
|
||||
if (metadata.kind === 'submit') {
|
||||
events.push('delay:' + metadata.label + ':pending');
|
||||
await submitDelay;
|
||||
}
|
||||
events.push('delay:' + metadata.label + ':2000');
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const location = {
|
||||
@@ -114,6 +130,7 @@ async function waitForElementByText() {
|
||||
|
||||
function fillInput(input, value) {
|
||||
input.value = value;
|
||||
events.push('fill-password:' + value);
|
||||
}
|
||||
|
||||
async function humanPause() {}
|
||||
@@ -125,14 +142,21 @@ function isStopError() {
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
events.push('log:' + message);
|
||||
}
|
||||
|
||||
function reportComplete(step, payload) {
|
||||
completions.push({ step, payload });
|
||||
events.push('report:' + payload.deferredSubmit);
|
||||
}
|
||||
|
||||
function simulateClick(target) {
|
||||
clicks.push(target.textContent || 'button');
|
||||
events.push('click:' + (target.textContent || 'button'));
|
||||
}
|
||||
|
||||
function getOperationDelayRunner() {
|
||||
return window.CodexOperationDelay.performOperationWithDelay;
|
||||
}
|
||||
|
||||
${extractFunction('step3_fillEmailPassword')}
|
||||
@@ -145,28 +169,42 @@ return {
|
||||
if (!scheduled.length) {
|
||||
throw new Error('missing deferred submit');
|
||||
}
|
||||
await scheduled[0]();
|
||||
await scheduled[0].fn();
|
||||
},
|
||||
releaseSubmitDelay() {
|
||||
releaseSubmitDelay();
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
logs,
|
||||
completions,
|
||||
clicks,
|
||||
events,
|
||||
passwordValue: snapshot.passwordInput.value,
|
||||
scheduledCount: scheduled.length,
|
||||
scheduledDelayMs: scheduled[0]?.ms,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run({
|
||||
let settled = false;
|
||||
const tracked = api.run({
|
||||
email: 'user@example.com',
|
||||
password: 'Secret123!',
|
||||
}).then((value) => {
|
||||
settled = true;
|
||||
return value;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, true, 'step 3 must return completion before deferred submit can navigate');
|
||||
const result = await tracked;
|
||||
|
||||
const beforeSubmit = api.snapshot();
|
||||
assert.equal(beforeSubmit.passwordValue, 'Secret123!');
|
||||
assert.equal(beforeSubmit.scheduledCount, 1);
|
||||
assert.equal(beforeSubmit.scheduledDelayMs, 120);
|
||||
assert.deepStrictEqual(beforeSubmit.clicks, []);
|
||||
assert.equal(beforeSubmit.completions.length, 1);
|
||||
assert.equal(beforeSubmit.completions[0].step, 3);
|
||||
@@ -174,9 +212,24 @@ return {
|
||||
assert.equal(result.email, 'user@example.com');
|
||||
assert.equal(result.deferredSubmit, true);
|
||||
assert.equal(typeof result.signupVerificationRequestedAt, 'number');
|
||||
assert.equal(beforeSubmit.events.includes('report:true'), true);
|
||||
assert.equal(beforeSubmit.events.includes('operation:submit-signup-password:start'), false);
|
||||
|
||||
await api.flushDeferredSubmit();
|
||||
let flushSettled = false;
|
||||
const flushed = api.flushDeferredSubmit().then(() => {
|
||||
flushSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const duringSubmit = api.snapshot();
|
||||
assert.equal(duringSubmit.events.includes('operation:submit-signup-password:start'), true);
|
||||
assert.equal(duringSubmit.events.includes('delay:submit-signup-password:pending'), true);
|
||||
assert.equal(flushSettled, false);
|
||||
|
||||
api.releaseSubmitDelay();
|
||||
await flushed;
|
||||
|
||||
const afterSubmit = api.snapshot();
|
||||
assert.deepStrictEqual(afterSubmit.clicks, ['Continue']);
|
||||
assert.equal(afterSubmit.events.includes('delay:submit-signup-password:2000'), true);
|
||||
});
|
||||
|
||||
@@ -57,9 +57,24 @@ test('fillVerificationCode submits after split inputs are stably filled', async
|
||||
const logs = [];
|
||||
const clicks = [];
|
||||
const filledValues = [];
|
||||
const splitCodeEvents = [];
|
||||
let activeOperationLabel = '';
|
||||
let submitClicked = false;
|
||||
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[data-verification-code]';
|
||||
const location = { href: 'https://auth.openai.com/email-verification' };
|
||||
const window = {
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
splitCodeEvents.push(\`operation:\${metadata.label}:start\`);
|
||||
activeOperationLabel = metadata.label;
|
||||
const result = await operation();
|
||||
activeOperationLabel = '';
|
||||
splitCodeEvents.push(\`operation:\${metadata.label}:end\`);
|
||||
splitCodeEvents.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
function KeyboardEvent(type, init = {}) {
|
||||
this.type = type;
|
||||
Object.assign(this, init);
|
||||
@@ -113,8 +128,16 @@ async function handle405ResendError() {}
|
||||
function fillInput(el, value) {
|
||||
el.value = value;
|
||||
filledValues.push(value);
|
||||
const splitIndex = inputs.indexOf(el);
|
||||
if (splitIndex >= 0) {
|
||||
splitCodeEvents.push(\`fill-code:\${splitIndex}\`);
|
||||
}
|
||||
}
|
||||
async function sleep(ms) {
|
||||
if (activeOperationLabel) {
|
||||
splitCodeEvents.push(\`sleep:\${activeOperationLabel}:\${ms}\`);
|
||||
}
|
||||
}
|
||||
async function sleep() {}
|
||||
async function waitForDocumentLoadComplete() {}
|
||||
function isStep5Ready() { return false; }
|
||||
function isStep8Ready() { return false; }
|
||||
@@ -125,7 +148,7 @@ function isVisibleElement() { return true; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
async function humanPause() {}
|
||||
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
|
||||
function simulateClick(el) { el.click(); clicks.push(el.textContent); splitCodeEvents.push('click:submit-code'); }
|
||||
async function waitForVerificationSubmitOutcome() { return { success: true }; }
|
||||
|
||||
${extractFunction('getVisibleSplitVerificationInputs')}
|
||||
@@ -150,6 +173,7 @@ return {
|
||||
logs,
|
||||
clicks,
|
||||
filledValues,
|
||||
splitCodeEvents,
|
||||
submitClicked,
|
||||
currentValue: inputs.map((input) => input.value).join(''),
|
||||
};
|
||||
@@ -164,6 +188,22 @@ return {
|
||||
assert.equal(snapshot.currentValue, '123456');
|
||||
assert.equal(snapshot.submitClicked, true);
|
||||
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
|
||||
assert.deepStrictEqual(snapshot.splitCodeEvents, [
|
||||
'operation:split-code:start',
|
||||
'fill-code:0',
|
||||
'fill-code:1',
|
||||
'fill-code:2',
|
||||
'fill-code:3',
|
||||
'fill-code:4',
|
||||
'fill-code:5',
|
||||
'operation:split-code:end',
|
||||
'delay:split-code:2000',
|
||||
'operation:submit-code:start',
|
||||
'click:submit-code',
|
||||
'operation:submit-code:end',
|
||||
'delay:submit-code:2000',
|
||||
]);
|
||||
assert.equal(snapshot.splitCodeEvents.filter((event) => event.startsWith('delay:split-code')).length, 1);
|
||||
});
|
||||
|
||||
test('fillVerificationCode does not short-circuit on mixed email-verification profile page before verification exits', async () => {
|
||||
@@ -895,3 +935,295 @@ return {
|
||||
assert.equal(result.clicks.length, 0);
|
||||
assert.equal(result.logs.some(({ message }) => /检测到密码页报错/.test(message)), true);
|
||||
});
|
||||
|
||||
test('fillSignupEmailAndContinue waits for submit operation delay before returning', async () => {
|
||||
const api = new Function(`
|
||||
const events = [];
|
||||
let releaseSubmitDelay;
|
||||
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
|
||||
const emailInput = { value: '' };
|
||||
const continueButton = { textContent: 'Continue' };
|
||||
const location = { href: 'https://auth.openai.com/u/signup' };
|
||||
const window = {
|
||||
setTimeout(callback, ms) {
|
||||
events.push(\`timer:\${ms}\`);
|
||||
return 1;
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
events.push(\`operation:\${metadata.label}:start\`);
|
||||
const result = await operation();
|
||||
events.push(\`operation:\${metadata.label}:end\`);
|
||||
if (metadata.kind === 'submit') {
|
||||
events.push(\`delay:\${metadata.label}:pending\`);
|
||||
await submitDelay;
|
||||
}
|
||||
events.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
|
||||
function throwIfStopped() {}
|
||||
function isStopError() { return false; }
|
||||
function log() {}
|
||||
async function humanPause() {}
|
||||
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
|
||||
function fillInput(input, value) { input.value = value; events.push(\`fill:\${value}\`); }
|
||||
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
|
||||
async function waitForSignupEntryState() {
|
||||
return { state: 'email_entry', emailInput, continueButton, url: location.href };
|
||||
}
|
||||
function getSignupEmailContinueButton() { return continueButton; }
|
||||
function isActionEnabled() { return true; }
|
||||
|
||||
${extractFunction('fillSignupEmailAndContinue')}
|
||||
|
||||
return {
|
||||
events,
|
||||
releaseSubmitDelay() { releaseSubmitDelay(); },
|
||||
run() { return fillSignupEmailAndContinue('ada@example.com', 2); },
|
||||
};
|
||||
`)();
|
||||
|
||||
let settled = false;
|
||||
const tracked = api.run().then((result) => {
|
||||
settled = true;
|
||||
api.events.push('run:resolved');
|
||||
return result;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, 'email submit must not return before submit delay resolves');
|
||||
assert.equal(api.events.includes('delay:submit-signup-email:pending'), true);
|
||||
assert.equal(api.events.includes('run:resolved'), false);
|
||||
|
||||
api.releaseSubmitDelay();
|
||||
const result = await tracked;
|
||||
assert.equal(result.submitted, true);
|
||||
assert.deepStrictEqual(api.events, [
|
||||
'operation:signup-email:start',
|
||||
'fill:ada@example.com',
|
||||
'operation:signup-email:end',
|
||||
'delay:signup-email:2000',
|
||||
'sleep:120',
|
||||
'operation:submit-signup-email:start',
|
||||
'click:Continue',
|
||||
'operation:submit-signup-email:end',
|
||||
'delay:submit-signup-email:pending',
|
||||
'delay:submit-signup-email:2000',
|
||||
'run:resolved',
|
||||
]);
|
||||
});
|
||||
|
||||
test('submitSignupPhoneNumberAndContinue waits for submit operation delay before returning', async () => {
|
||||
const api = new Function(`
|
||||
const events = [];
|
||||
let releaseSubmitDelay;
|
||||
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
|
||||
const phoneInput = { value: '' };
|
||||
const continueButton = { textContent: 'Continue' };
|
||||
const location = { href: 'https://auth.openai.com/u/signup/phone' };
|
||||
const window = {
|
||||
setTimeout(callback, ms) {
|
||||
events.push(\`timer:\${ms}\`);
|
||||
return 1;
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
events.push(\`operation:\${metadata.label}:start\`);
|
||||
const result = await operation();
|
||||
events.push(\`operation:\${metadata.label}:end\`);
|
||||
if (metadata.kind === 'submit') {
|
||||
events.push(\`delay:\${metadata.label}:pending\`);
|
||||
await submitDelay;
|
||||
}
|
||||
events.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
|
||||
function throwIfStopped() {}
|
||||
function isStopError() { return false; }
|
||||
function log() {}
|
||||
async function humanPause() {}
|
||||
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
|
||||
function fillInput(input, value) { input.value = value; events.push(\`fill:\${value}\`); }
|
||||
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
|
||||
async function waitForSignupPhoneEntryState() {
|
||||
return { state: 'phone_entry', phoneInput, url: location.href };
|
||||
}
|
||||
async function ensureSignupPhoneCountrySelected() {
|
||||
return { hasCountryControl: false, matched: true, selectedOption: null };
|
||||
}
|
||||
function resolveSignupPhoneDialCode() { return '1'; }
|
||||
function toNationalPhoneNumber() { return '5551234567'; }
|
||||
function getSignupPhoneHiddenNumberInput() { return null; }
|
||||
function toE164PhoneNumber() { return '+15551234567'; }
|
||||
function getSignupEmailContinueButton() { return continueButton; }
|
||||
function isActionEnabled() { return true; }
|
||||
|
||||
${extractFunction('submitSignupPhoneNumberAndContinue')}
|
||||
|
||||
return {
|
||||
events,
|
||||
releaseSubmitDelay() { releaseSubmitDelay(); },
|
||||
run() { return submitSignupPhoneNumberAndContinue({ phoneNumber: '+15551234567' }); },
|
||||
};
|
||||
`)();
|
||||
|
||||
let settled = false;
|
||||
const tracked = api.run().then((result) => {
|
||||
settled = true;
|
||||
api.events.push('run:resolved');
|
||||
return result;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, false, 'phone submit must not return before submit delay resolves');
|
||||
assert.equal(api.events.includes('delay:submit-signup-phone:pending'), true);
|
||||
assert.equal(api.events.includes('run:resolved'), false);
|
||||
|
||||
api.releaseSubmitDelay();
|
||||
const result = await tracked;
|
||||
assert.equal(result.submitted, true);
|
||||
assert.deepStrictEqual(api.events, [
|
||||
'operation:signup-phone-number:start',
|
||||
'fill:5551234567',
|
||||
'operation:signup-phone-number:end',
|
||||
'delay:signup-phone-number:2000',
|
||||
'sleep:120',
|
||||
'operation:submit-signup-phone:start',
|
||||
'click:Continue',
|
||||
'operation:submit-signup-phone:end',
|
||||
'delay:submit-signup-phone:pending',
|
||||
'delay:submit-signup-phone:2000',
|
||||
'run:resolved',
|
||||
]);
|
||||
});
|
||||
|
||||
test('step3_fillEmailPassword reports complete before deferred submit while submit still waits for operation delay', async () => {
|
||||
const api = new Function(`
|
||||
const events = [];
|
||||
const reports = [];
|
||||
const scheduled = [];
|
||||
let releaseSubmitDelay;
|
||||
const submitDelay = new Promise((resolve) => { releaseSubmitDelay = resolve; });
|
||||
const Date = { now: () => 12345 };
|
||||
const passwordInput = { value: '' };
|
||||
const submitButton = { textContent: 'Continue' };
|
||||
const location = { href: 'https://auth.openai.com/u/signup/password' };
|
||||
const window = {
|
||||
setTimeout(callback, ms) {
|
||||
events.push(\`timer:\${ms}\`);
|
||||
scheduled.push(callback);
|
||||
return scheduled.length;
|
||||
},
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
events.push(\`operation:\${metadata.label}:start\`);
|
||||
const result = await operation();
|
||||
events.push(\`operation:\${metadata.label}:end\`);
|
||||
if (metadata.kind === 'submit') {
|
||||
events.push(\`delay:\${metadata.label}:pending\`);
|
||||
await submitDelay;
|
||||
}
|
||||
events.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function getOperationDelayRunner() { return window.CodexOperationDelay.performOperationWithDelay; }
|
||||
function throwIfStopped() {}
|
||||
function isStopError() { return false; }
|
||||
function log(message) { events.push(\`log:\${message}\`); }
|
||||
async function humanPause() {}
|
||||
async function sleep(ms) { events.push(\`sleep:\${ms}\`); }
|
||||
function fillInput(input, value) { input.value = value; events.push(\`fill-password:\${value}\`); }
|
||||
function simulateClick(el) { events.push(\`click:\${el.textContent}\`); }
|
||||
function inspectSignupEntryState() {
|
||||
return { state: 'password_page', passwordInput, submitButton, displayedEmail: 'ada@example.com' };
|
||||
}
|
||||
function getSignupPasswordSubmitButton() { return submitButton; }
|
||||
async function waitForElementByText() { return null; }
|
||||
function logSignupPasswordDiagnostics() {}
|
||||
function reportComplete(step, payload) {
|
||||
reports.push({ step, payload });
|
||||
events.push(\`report:\${payload.deferredSubmit}\`);
|
||||
}
|
||||
|
||||
${extractFunction('step3_fillEmailPassword')}
|
||||
|
||||
return {
|
||||
events,
|
||||
reports,
|
||||
scheduledCount() { return scheduled.length; },
|
||||
async flushDeferredSubmit() {
|
||||
if (!scheduled.length) throw new Error('missing deferred submit');
|
||||
await scheduled[0]();
|
||||
},
|
||||
releaseSubmitDelay() { releaseSubmitDelay(); },
|
||||
run() { return step3_fillEmailPassword({ email: 'ada@example.com', password: 'Secret123!' }); },
|
||||
};
|
||||
`)();
|
||||
|
||||
let settled = false;
|
||||
const tracked = api.run().then((result) => {
|
||||
settled = true;
|
||||
api.events.push('run:resolved');
|
||||
return result;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(settled, true, 'step 3 must report and return before deferred submit can navigate');
|
||||
const result = await tracked;
|
||||
assert.equal(result.deferredSubmit, true);
|
||||
assert.equal(api.reports.length, 1);
|
||||
assert.equal(api.scheduledCount(), 1);
|
||||
assert.equal(api.events.includes('operation:submit-signup-password:start'), false);
|
||||
assert.deepStrictEqual(api.events, [
|
||||
'operation:signup-password:start',
|
||||
'fill-password:Secret123!',
|
||||
'operation:signup-password:end',
|
||||
'delay:signup-password:2000',
|
||||
'log:步骤 3:密码已填写',
|
||||
'report:true',
|
||||
'timer:120',
|
||||
'run:resolved',
|
||||
]);
|
||||
|
||||
let flushed = false;
|
||||
const flush = api.flushDeferredSubmit().then(() => {
|
||||
flushed = true;
|
||||
api.events.push('flush:resolved');
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(api.events.includes('operation:submit-signup-password:start'), true);
|
||||
assert.equal(api.events.includes('delay:submit-signup-password:pending'), true);
|
||||
assert.equal(flushed, false, 'deferred submit callback must wait for submit delay before resolving');
|
||||
|
||||
api.releaseSubmitDelay();
|
||||
await flush;
|
||||
assert.deepStrictEqual(api.events, [
|
||||
'operation:signup-password:start',
|
||||
'fill-password:Secret123!',
|
||||
'operation:signup-password:end',
|
||||
'delay:signup-password:2000',
|
||||
'log:步骤 3:密码已填写',
|
||||
'report:true',
|
||||
'timer:120',
|
||||
'run:resolved',
|
||||
'sleep:500',
|
||||
'operation:submit-signup-password:start',
|
||||
'click:Continue',
|
||||
'operation:submit-signup-password:end',
|
||||
'delay:submit-signup-password:pending',
|
||||
'delay:submit-signup-password:2000',
|
||||
'log:步骤 3:表单已提交',
|
||||
'flush:resolved',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -227,3 +227,140 @@ return {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 5 routes fallback native consent checkbox click through operation delay', async () => {
|
||||
const api = new Function(`
|
||||
const operationEvents = [];
|
||||
let activeOperationLabel = '';
|
||||
|
||||
const nameInput = { value: '', hidden: false };
|
||||
const ageInput = { value: '', hidden: false };
|
||||
const completeButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: '\\u5b8c\\u6210\\u8d26\\u6237\\u521b\\u5efa',
|
||||
hidden: false,
|
||||
};
|
||||
const allConsentLabel = {
|
||||
hidden: false,
|
||||
textContent: '\\u6211\\u540c\\u610f\\u4ee5\\u4e0b\\u6240\\u6709\\u5404\\u9879',
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const allConsentCheckbox = {
|
||||
checked: false,
|
||||
hidden: true,
|
||||
name: 'allCheckboxes',
|
||||
type: 'checkbox',
|
||||
click() {
|
||||
operationEvents.push(\`native-click:\${activeOperationLabel || 'outside'}\`);
|
||||
this.checked = true;
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'name') return this.name;
|
||||
if (name === 'type') return this.type;
|
||||
return '';
|
||||
},
|
||||
closest(selector) {
|
||||
if (selector === 'label') return allConsentLabel;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const window = {
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
operationEvents.push(\`operation:\${metadata.label}:start\`);
|
||||
activeOperationLabel = metadata.label;
|
||||
const result = await operation();
|
||||
activeOperationLabel = '';
|
||||
operationEvents.push(\`operation:\${metadata.label}:end\`);
|
||||
operationEvents.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
case 'input[name="birthday"]':
|
||||
return null;
|
||||
case 'input[name="age"]':
|
||||
return ageInput;
|
||||
case 'button[type="submit"]':
|
||||
return completeButton;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') {
|
||||
return [allConsentCheckbox];
|
||||
}
|
||||
if (selector === 'input[type="checkbox"]') {
|
||||
return [allConsentCheckbox];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
execCommand() {},
|
||||
};
|
||||
|
||||
const location = { href: 'https://auth.openai.com/u/signup/profile' };
|
||||
function log() {}
|
||||
async function waitForElement() { return nameInput; }
|
||||
async function humanPause() {}
|
||||
async function sleep() {}
|
||||
function fillInput(input, value) { input.value = value; }
|
||||
function findBirthdayReactAriaSelect() { return null; }
|
||||
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
|
||||
async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run in age-mode test'); }
|
||||
async function waitForElementByText() { throw new Error('waitForElementByText should not run in this test'); }
|
||||
function simulateClick(el) {
|
||||
operationEvents.push(\`simulate-click:\${activeOperationLabel || 'outside'}:\${el.textContent || el.tagName || 'element'}\`);
|
||||
}
|
||||
function reportComplete() {}
|
||||
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
|
||||
|
||||
${getStep5Bundle()}
|
||||
|
||||
return {
|
||||
async run(payload) {
|
||||
return step5_fillNameBirthday(payload);
|
||||
},
|
||||
snapshot() {
|
||||
return { operationEvents, consentChecked: allConsentCheckbox.checked };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.run({
|
||||
firstName: 'Mia',
|
||||
lastName: 'Harris',
|
||||
age: 19,
|
||||
});
|
||||
|
||||
const { operationEvents, consentChecked } = api.snapshot();
|
||||
assert.equal(consentChecked, true);
|
||||
assert.equal(operationEvents.includes('native-click:outside'), false);
|
||||
assert.ok(
|
||||
operationEvents.indexOf('delay:accept-profile-consent:2000')
|
||||
< operationEvents.indexOf('operation:accept-profile-consent-fallback:start'),
|
||||
'fallback click must be a separate delayed operation after the first consent attempt'
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
operationEvents.slice(
|
||||
operationEvents.indexOf('operation:accept-profile-consent-fallback:start'),
|
||||
operationEvents.indexOf('delay:accept-profile-consent-fallback:2000') + 1
|
||||
),
|
||||
[
|
||||
'operation:accept-profile-consent-fallback:start',
|
||||
'native-click:accept-profile-consent-fallback',
|
||||
'operation:accept-profile-consent-fallback:end',
|
||||
'delay:accept-profile-consent-fallback:2000',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,3 +224,392 @@ return {
|
||||
'日志应明确说明 Step 5 已直接完成'
|
||||
);
|
||||
});
|
||||
|
||||
test('step 5 routes profile fill and submit operations through operation delay', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const completions = [];
|
||||
const profileEvents = [];
|
||||
|
||||
const nameInput = { value: '', hidden: false };
|
||||
const ageInput = { value: '', hidden: false };
|
||||
const completeButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: '完成帐户创建',
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
const window = {
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
profileEvents.push(\`operation:\${metadata.label}:start\`);
|
||||
const result = await operation();
|
||||
profileEvents.push(\`operation:\${metadata.label}:end\`);
|
||||
profileEvents.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
case 'input[name="birthday"]':
|
||||
return null;
|
||||
case 'input[name="age"]':
|
||||
return ageInput;
|
||||
case 'button[type="submit"]':
|
||||
return completeButton;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[name="allCheckboxes"][type="checkbox"]') {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
execCommand() {},
|
||||
};
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/u/signup/profile',
|
||||
};
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return nameInput;
|
||||
}
|
||||
|
||||
async function humanPause() {}
|
||||
async function sleep() {}
|
||||
|
||||
function fillInput(input, value) {
|
||||
input.value = value;
|
||||
profileEvents.push(input === nameInput ? 'fill:name' : 'fill:birthday');
|
||||
}
|
||||
|
||||
function findBirthdayReactAriaSelect() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isVisibleElement(el) {
|
||||
return Boolean(el) && !el.hidden;
|
||||
}
|
||||
|
||||
async function setReactAriaBirthdaySelect() {}
|
||||
|
||||
async function waitForElementByText() {
|
||||
throw new Error('waitForElementByText should not run in this test');
|
||||
}
|
||||
|
||||
function simulateClick() {
|
||||
profileEvents.push('click:complete');
|
||||
}
|
||||
|
||||
function reportComplete(step, payload) {
|
||||
completions.push({ step, payload });
|
||||
}
|
||||
|
||||
function normalizeInlineText(text) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
${getStep5Bundle()}
|
||||
|
||||
return {
|
||||
async run(payload) {
|
||||
return step5_fillNameBirthday(payload);
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
completions,
|
||||
profileEvents,
|
||||
nameValue: nameInput.value,
|
||||
ageValue: ageInput.value,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.run({
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
age: 23,
|
||||
});
|
||||
|
||||
const snapshot = api.snapshot();
|
||||
assert.deepStrictEqual(snapshot.profileEvents, [
|
||||
'operation:fill-name:start',
|
||||
'fill:name',
|
||||
'operation:fill-name:end',
|
||||
'delay:fill-name:2000',
|
||||
'operation:fill-birthday:start',
|
||||
'fill:birthday',
|
||||
'operation:fill-birthday:end',
|
||||
'delay:fill-birthday:2000',
|
||||
'operation:submit-profile:start',
|
||||
'click:complete',
|
||||
'operation:submit-profile:end',
|
||||
'delay:submit-profile:2000',
|
||||
]);
|
||||
assert.ok(snapshot.profileEvents.indexOf('operation:fill-name:start') < snapshot.profileEvents.indexOf('delay:fill-name:2000'));
|
||||
assert.equal(snapshot.nameValue, 'Test User');
|
||||
assert.equal(snapshot.ageValue, '23');
|
||||
});
|
||||
|
||||
test('step 5 routes React Aria birthday dropdown selections through per-field operation delays', async () => {
|
||||
const api = new Function(`
|
||||
const operationEvents = [];
|
||||
const selectedBirthday = {};
|
||||
let activeOperationLabel = '';
|
||||
|
||||
const nameInput = { value: '', hidden: false };
|
||||
const hiddenBirthday = { value: '', hidden: false, dispatchEvent() {} };
|
||||
const birthdaySelects = {
|
||||
'\\u5e74': { field: 'year', label: '\\u5e74', button: { hidden: false }, nativeSelect: {} },
|
||||
'\\u6708': { field: 'month', label: '\\u6708', button: { hidden: false }, nativeSelect: {} },
|
||||
'\\u5929': { field: 'day', label: '\\u5929', button: { hidden: false }, nativeSelect: {} },
|
||||
};
|
||||
|
||||
const window = {
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
operationEvents.push(\`operation:\${metadata.label}:start\`);
|
||||
activeOperationLabel = metadata.label;
|
||||
const result = await operation();
|
||||
activeOperationLabel = '';
|
||||
operationEvents.push(\`operation:\${metadata.label}:end\`);
|
||||
operationEvents.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
case 'input[name="age"]':
|
||||
case 'button[type="submit"]':
|
||||
return null;
|
||||
case 'input[name="birthday"]':
|
||||
return hiddenBirthday;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
execCommand() {},
|
||||
};
|
||||
|
||||
const location = { href: 'https://auth.openai.com/u/signup/profile' };
|
||||
function Event(type, init = {}) { this.type = type; this.bubbles = Boolean(init.bubbles); }
|
||||
function log() {}
|
||||
async function waitForElement() { return nameInput; }
|
||||
async function humanPause() {}
|
||||
async function sleep() {}
|
||||
function fillInput(input, value) { input.value = value; }
|
||||
function findBirthdayReactAriaSelect(label) { return birthdaySelects[label] || null; }
|
||||
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
|
||||
async function setReactAriaBirthdaySelect(select, value) {
|
||||
operationEvents.push(\`select:\${select.field}:\${activeOperationLabel || 'outside'}\`);
|
||||
selectedBirthday[select.field] = String(value).padStart(select.field === 'year' ? 4 : 2, '0');
|
||||
if (selectedBirthday.year && selectedBirthday.month && selectedBirthday.day) {
|
||||
hiddenBirthday.value = \`\${selectedBirthday.year}-\${selectedBirthday.month}-\${selectedBirthday.day}\`;
|
||||
}
|
||||
}
|
||||
async function waitForElementByText() { throw new Error('waitForElementByText should not run in prefill test'); }
|
||||
function simulateClick() { throw new Error('simulateClick should not run in prefill test'); }
|
||||
function reportComplete() {}
|
||||
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
|
||||
|
||||
${getStep5Bundle()}
|
||||
|
||||
return {
|
||||
async run(payload) { return step5_fillNameBirthday(payload); },
|
||||
snapshot() { return { operationEvents, birthdayValue: hiddenBirthday.value }; },
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run({
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
year: 2003,
|
||||
month: 6,
|
||||
day: 19,
|
||||
prefillOnly: true,
|
||||
});
|
||||
|
||||
const snapshot = api.snapshot();
|
||||
assert.deepStrictEqual(result, { prefilled: true });
|
||||
assert.equal(snapshot.birthdayValue, '2003-06-19');
|
||||
assert.deepStrictEqual(snapshot.operationEvents, [
|
||||
'operation:fill-name:start',
|
||||
'operation:fill-name:end',
|
||||
'delay:fill-name:2000',
|
||||
'operation:select-birthday-year:start',
|
||||
'select:year:select-birthday-year',
|
||||
'operation:select-birthday-year:end',
|
||||
'delay:select-birthday-year:2000',
|
||||
'operation:select-birthday-month:start',
|
||||
'select:month:select-birthday-month',
|
||||
'operation:select-birthday-month:end',
|
||||
'delay:select-birthday-month:2000',
|
||||
'operation:select-birthday-day:start',
|
||||
'select:day:select-birthday-day',
|
||||
'operation:select-birthday-day:end',
|
||||
'delay:select-birthday-day:2000',
|
||||
'operation:profile-dom-sync:start',
|
||||
'operation:profile-dom-sync:end',
|
||||
'delay:profile-dom-sync:2000',
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 5 routes birthday spinbutton fields through per-field operation delays', async () => {
|
||||
const api = new Function(`
|
||||
const operationEvents = [];
|
||||
let activeOperationLabel = '';
|
||||
|
||||
function createSpinner(name) {
|
||||
return {
|
||||
name,
|
||||
hidden: false,
|
||||
value: '',
|
||||
focus() {},
|
||||
blur() {},
|
||||
dispatchEvent(event) {
|
||||
if (event?.type === 'input' && event.data) {
|
||||
operationEvents.push(\`spin:\${name}:\${event.data}:\${activeOperationLabel || 'outside'}\`);
|
||||
this.value += event.data;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nameInput = { value: '', hidden: false };
|
||||
const yearSpinner = createSpinner('year');
|
||||
const monthSpinner = createSpinner('month');
|
||||
const daySpinner = createSpinner('day');
|
||||
|
||||
const window = {
|
||||
CodexOperationDelay: {
|
||||
async performOperationWithDelay(metadata, operation) {
|
||||
operationEvents.push(\`operation:\${metadata.label}:start\`);
|
||||
activeOperationLabel = metadata.label;
|
||||
const result = await operation();
|
||||
activeOperationLabel = '';
|
||||
operationEvents.push(\`operation:\${metadata.label}:end\`);
|
||||
operationEvents.push(\`delay:\${metadata.label}:2000\`);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
switch (selector) {
|
||||
case '[role="spinbutton"][data-type="year"]':
|
||||
return yearSpinner;
|
||||
case '[role="spinbutton"][data-type="month"]':
|
||||
return monthSpinner;
|
||||
case '[role="spinbutton"][data-type="day"]':
|
||||
return daySpinner;
|
||||
case 'input[name="birthday"]':
|
||||
case 'input[name="age"]':
|
||||
case 'button[type="submit"]':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
execCommand(command) {
|
||||
if (command === 'selectAll' && activeOperationLabel) {
|
||||
const target = {
|
||||
'fill-birthday-year': yearSpinner,
|
||||
'fill-birthday-month': monthSpinner,
|
||||
'fill-birthday-day': daySpinner,
|
||||
}[activeOperationLabel];
|
||||
if (target) target.value = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const location = { href: 'https://auth.openai.com/u/signup/profile' };
|
||||
function KeyboardEvent(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
function InputEvent(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
function log() {}
|
||||
async function waitForElement() { return nameInput; }
|
||||
async function humanPause() {}
|
||||
async function sleep() {}
|
||||
function fillInput(input, value) { input.value = value; }
|
||||
function findBirthdayReactAriaSelect() { return null; }
|
||||
function isVisibleElement(el) { return Boolean(el) && !el.hidden; }
|
||||
async function setReactAriaBirthdaySelect() { throw new Error('setReactAriaBirthdaySelect should not run in spinbutton test'); }
|
||||
async function waitForElementByText() { throw new Error('waitForElementByText should not run in prefill test'); }
|
||||
function simulateClick() { throw new Error('simulateClick should not run in prefill test'); }
|
||||
function reportComplete() {}
|
||||
function normalizeInlineText(text) { return String(text || '').replace(/\\s+/g, ' ').trim(); }
|
||||
|
||||
${getStep5Bundle()}
|
||||
|
||||
return {
|
||||
async run(payload) { return step5_fillNameBirthday(payload); },
|
||||
snapshot() {
|
||||
return {
|
||||
operationEvents,
|
||||
yearValue: yearSpinner.value,
|
||||
monthValue: monthSpinner.value,
|
||||
dayValue: daySpinner.value,
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run({
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
year: 2003,
|
||||
month: 6,
|
||||
day: 19,
|
||||
prefillOnly: true,
|
||||
});
|
||||
|
||||
const snapshot = api.snapshot();
|
||||
assert.deepStrictEqual(result, { prefilled: true });
|
||||
assert.equal(snapshot.yearValue, '2003');
|
||||
assert.equal(snapshot.monthValue, '06');
|
||||
assert.equal(snapshot.dayValue, '19');
|
||||
assert.deepStrictEqual(snapshot.operationEvents, [
|
||||
'operation:fill-name:start',
|
||||
'operation:fill-name:end',
|
||||
'delay:fill-name:2000',
|
||||
'operation:fill-birthday-year:start',
|
||||
'spin:year:2:fill-birthday-year',
|
||||
'spin:year:0:fill-birthday-year',
|
||||
'spin:year:0:fill-birthday-year',
|
||||
'spin:year:3:fill-birthday-year',
|
||||
'operation:fill-birthday-year:end',
|
||||
'delay:fill-birthday-year:2000',
|
||||
'operation:fill-birthday-month:start',
|
||||
'spin:month:0:fill-birthday-month',
|
||||
'spin:month:6:fill-birthday-month',
|
||||
'operation:fill-birthday-month:end',
|
||||
'delay:fill-birthday-month:2000',
|
||||
'operation:fill-birthday-day:start',
|
||||
'spin:day:1:fill-birthday-day',
|
||||
'spin:day:9:fill-birthday-day',
|
||||
'operation:fill-birthday-day:end',
|
||||
'delay:fill-birthday-day:2000',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.js', 'utf8');
|
||||
const phoneAuthSource = fs.readFileSync('content/phone-auth.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
@@ -694,3 +695,171 @@ test('step 7 stops before submit when phone fill never includes the local number
|
||||
assert.equal(api.getValue(), '+44');
|
||||
assert.deepEqual(api.getFills(), ['+447780579093', '7780579093', '+447780579093', '7780579093']);
|
||||
});
|
||||
|
||||
function createPhoneAuthSubmitHarness(runModeEvents, runMode) {
|
||||
const selectedOption = { value: 'GB', textContent: 'United Kingdom (+44)' };
|
||||
const select = {
|
||||
value: 'GB',
|
||||
selectedIndex: 0,
|
||||
options: [selectedOption],
|
||||
dispatchEvent() {},
|
||||
};
|
||||
const phoneInput = {
|
||||
value: '',
|
||||
closest() {
|
||||
return addPhoneForm;
|
||||
},
|
||||
};
|
||||
const hiddenPhoneNumberInput = {
|
||||
value: '',
|
||||
events: [],
|
||||
dispatchEvent(event) {
|
||||
this.events.push(event.type);
|
||||
},
|
||||
};
|
||||
const submitButton = {
|
||||
disabled: false,
|
||||
textContent: 'Continue',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const dialCodeSpan = { textContent: '44' };
|
||||
let phoneVerificationReady = false;
|
||||
|
||||
const addPhoneForm = {
|
||||
querySelector(selector) {
|
||||
if (selector === 'input[type="tel"], input[name="__reservedForPhoneNumberInput_tel"], input[autocomplete="tel"]') {
|
||||
return phoneInput;
|
||||
}
|
||||
if (selector === 'input[name="phoneNumber"]') {
|
||||
return hiddenPhoneNumberInput;
|
||||
}
|
||||
if (selector === 'select') {
|
||||
return select;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'button[type="submit"], input[type="submit"]') {
|
||||
return [submitButton];
|
||||
}
|
||||
if (selector === 'span') {
|
||||
return [dialCodeSpan];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const phoneVerificationForm = {
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
document: {
|
||||
querySelector(selector) {
|
||||
if (selector === 'form[action*="/add-phone" i]') {
|
||||
return addPhoneForm;
|
||||
}
|
||||
if (selector === 'form[action*="/phone-verification" i]') {
|
||||
return phoneVerificationReady ? phoneVerificationForm : null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
title: '',
|
||||
},
|
||||
location: {
|
||||
href: 'https://auth.openai.com/add-phone',
|
||||
pathname: '/add-phone',
|
||||
},
|
||||
Event: function Event(type, init = {}) {
|
||||
this.type = type;
|
||||
this.bubbles = Boolean(init.bubbles);
|
||||
},
|
||||
};
|
||||
const performOperationWithDelay = async (metadata, operation) => {
|
||||
const result = await operation();
|
||||
runModeEvents[runMode].push({
|
||||
delayMs: 2000,
|
||||
kind: metadata.kind,
|
||||
label: metadata.label,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
root.CodexOperationDelay = { performOperationWithDelay };
|
||||
|
||||
const phoneAuthModule = new Function('self', 'globalThis', `
|
||||
const document = self.document;
|
||||
const location = self.location;
|
||||
const Event = self.Event;
|
||||
${phoneAuthSource}
|
||||
return self.MultiPagePhoneAuth;
|
||||
`)(root, root);
|
||||
|
||||
const helpers = phoneAuthModule.createPhoneAuthHelpers({
|
||||
fillInput(input, value) {
|
||||
input.value = value;
|
||||
},
|
||||
getActionText(element) {
|
||||
return element?.textContent || '';
|
||||
},
|
||||
getPageTextSnapshot() {
|
||||
return '';
|
||||
},
|
||||
getVerificationErrorText() {
|
||||
return '';
|
||||
},
|
||||
humanPause: async () => {},
|
||||
isActionEnabled(element) {
|
||||
return Boolean(element) && !element.disabled && element.getAttribute?.('aria-disabled') !== 'true';
|
||||
},
|
||||
isAddPhonePageReady() {
|
||||
return true;
|
||||
},
|
||||
isConsentReady() {
|
||||
return false;
|
||||
},
|
||||
isPhoneVerificationPageReady() {
|
||||
return phoneVerificationReady;
|
||||
},
|
||||
isVisibleElement(element) {
|
||||
return Boolean(element);
|
||||
},
|
||||
performOperationWithDelay,
|
||||
simulateClick(element) {
|
||||
if (element === submitButton) {
|
||||
phoneVerificationReady = true;
|
||||
}
|
||||
},
|
||||
sleep: async () => {},
|
||||
throwIfStopped() {},
|
||||
waitForElement: async () => phoneInput,
|
||||
});
|
||||
|
||||
return { helpers };
|
||||
}
|
||||
|
||||
test('phone auth operation delay metadata is identical for auto and manual submit runs', async () => {
|
||||
const runModeEvents = { auto: [], manual: [] };
|
||||
|
||||
for (const runMode of ['auto', 'manual']) {
|
||||
const { helpers } = createPhoneAuthSubmitHarness(runModeEvents, runMode);
|
||||
const result = await helpers.submitPhoneNumber({
|
||||
countryLabel: 'United Kingdom',
|
||||
phoneNumber: '447780579093',
|
||||
runMode,
|
||||
});
|
||||
assert.equal(result.phoneVerificationPage, true);
|
||||
}
|
||||
|
||||
assert.ok(runModeEvents.auto.length > 0);
|
||||
assert.deepStrictEqual(runModeEvents.auto.map((event) => event.delayMs), runModeEvents.manual.map((event) => event.delayMs));
|
||||
assert.deepStrictEqual(runModeEvents.auto.map((event) => event.kind), runModeEvents.manual.map((event) => event.kind));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user