diff --git a/e2e/binance-orderbook/fixtures/binance-futures.js b/e2e/binance-orderbook/fixtures/binance-futures.js index a4c2fad..2e7c4d1 100644 --- a/e2e/binance-orderbook/fixtures/binance-futures.js +++ b/e2e/binance-orderbook/fixtures/binance-futures.js @@ -135,6 +135,7 @@ export function renderBinanceFuturesFixture(scenario) { const visibleOrders = () => state.hideOtherSymbols ? currentOrders() : state.orders; const selected = (value, expected) => String(value === expected); const scheduleCommit = (callback) => setTimeout(callback, scenario.host.mutationDelayMs); + let orderSubmitSequence = 0; const userscriptFetch = window.fetch; window.fetch = async (...args) => { const response = await userscriptFetch(...args); @@ -182,15 +183,56 @@ export function renderBinanceFuturesFixture(scenario) { }); orderEntry.querySelectorAll('button').forEach((button) => { button.addEventListener('click', () => { - const feedback = document.createElement('div'); - feedback.setAttribute('role', 'alert'); - feedback.textContent = '订单已提交成功'; - document.body.append(feedback); + const busyAttribute = scenario.host.submitButtonBusyAttribute; + if (button.getAttribute(busyAttribute) === 'true') { + record('order-submit-while-busy', { action: button.textContent.trim() }); + return; + } + if (scenario.host.submitButtonBusyMs > 0) { + button.setAttribute(busyAttribute, 'true'); + record('submit-button-busy', { action: button.textContent.trim() }); + setTimeout(() => { + if (scenario.host.submitButtonClearsInputsWhenReady) { + orderEntry.querySelectorAll('input').forEach((input) => { + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + record('native-submit-cleanup-cleared-inputs'); + } + button.removeAttribute(busyAttribute); + record('submit-button-ready', { action: button.textContent.trim() }); + }, scenario.host.submitButtonBusyMs); + } + orderSubmitSequence += 1; + const submitSequence = orderSubmitSequence; record('order-submitted', { + submitSequence, action: button.textContent.trim(), price: orderEntry.querySelector('input[id^="limitPrice-"]')?.value || '', quantity: orderEntry.querySelector('input[id^="unitAmount-"]')?.value || '', }); + window.fetch('/bapi/futures/v1/private/future/order/place-order', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ submitSequence }), + }).then(() => record('order-submit-api-success', { submitSequence })); + const showFeedback = () => { + const feedback = document.createElement('div'); + feedback.setAttribute('role', 'alert'); + feedback.textContent = '订单已提交成功'; + document.body.append(feedback); + record('order-submit-feedback', { action: button.textContent.trim(), submitSequence }); + }; + if (scenario.host.submitFeedbackDelayMs > 0) { + setTimeout(showFeedback, scenario.host.submitFeedbackDelayMs); + } else { + showFeedback(); + } + }); + }); + orderEntry.querySelectorAll('input').forEach((input) => { + input.addEventListener('input', () => { + record('trade-input-written', { id: input.id, value: input.value }); }); }); } diff --git a/e2e/binance-orderbook/helpers/userscript-page.js b/e2e/binance-orderbook/helpers/userscript-page.js index 9d3ad7e..83bf292 100644 --- a/e2e/binance-orderbook/helpers/userscript-page.js +++ b/e2e/binance-orderbook/helpers/userscript-page.js @@ -28,6 +28,7 @@ export async function openUserscriptScenario(page, scenario) { sha256: createHash('sha256').update(userscriptSource).digest('hex'), }; evidenceByPage.set(page, { scenario, userscript, errors }); + let placeOrderRequestCount = 0; await page.route('https://www.binance.com/**', async (route) => { const url = new URL(route.request().url()); if (url.pathname === '/__binance_orderbook_userscript__.js') { @@ -46,6 +47,20 @@ export async function openUserscriptScenario(page, scenario) { }); return; } + if (url.pathname === '/bapi/futures/v1/private/future/order/place-order') { + const delayMs = scenario.host.submitApiResponseDelayMsByOrder[placeOrderRequestCount]; + placeOrderRequestCount += 1; + if (delayMs === undefined) { + throw new Error('Fixture received more than five ladder order requests'); + } + if (delayMs > 0) await new Promise((resolve) => setTimeout(resolve, delayMs)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ success: true }), + }); + return; + } if (url.pathname === '/bapi/futures/v6/private/future/user-data/user-position') { await route.fulfill({ status: 200, diff --git a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js index 75da223..4ca2ff7 100644 --- a/e2e/binance-orderbook/scenarios/cancel-current-symbol.js +++ b/e2e/binance-orderbook/scenarios/cancel-current-symbol.js @@ -56,6 +56,11 @@ export function createCancelScenario(overrides = {}) { dialogReplacementDelayMs: null, clearMode: 'currentSymbol', chartOrdersPopoverCloseMode: 'normal', + submitFeedbackDelayMs: 0, + submitButtonBusyMs: 0, + submitButtonBusyAttribute: 'data-loading', + submitButtonClearsInputsWhenReady: false, + submitApiResponseDelayMsByOrder: [0, 0, 0, 0, 0], precisionOptions: ['0.001', '0.01', '0.1', '1'], ...overrides.host, }, @@ -90,6 +95,26 @@ export function createCancelScenario(overrides = {}) { `Unsupported chart-orders popover close mode: ${scenario.host.chartOrdersPopoverCloseMode}`, ); } + for (const key of ['submitFeedbackDelayMs', 'submitButtonBusyMs']) { + if (!Number.isInteger(scenario.host[key]) || scenario.host[key] < 0) { + throw new Error(`${key} must be a non-negative integer`); + } + } + if (!['data-loading', 'aria-busy'].includes(scenario.host.submitButtonBusyAttribute)) { + throw new Error(`Unsupported submit button busy attribute: ${scenario.host.submitButtonBusyAttribute}`); + } + if (typeof scenario.host.submitButtonClearsInputsWhenReady !== 'boolean') { + throw new Error('submitButtonClearsInputsWhenReady must be a boolean'); + } + if ( + !Array.isArray(scenario.host.submitApiResponseDelayMsByOrder) + || scenario.host.submitApiResponseDelayMsByOrder.length !== 5 + || scenario.host.submitApiResponseDelayMsByOrder.some( + (delayMs) => !Number.isInteger(delayMs) || delayMs < 0, + ) + ) { + throw new Error('submitApiResponseDelayMsByOrder must contain five non-negative integers'); + } if ( scenario.host.dialogReplacementDelayMs !== null && (!Number.isInteger(scenario.host.dialogReplacementDelayMs) diff --git a/e2e/binance-orderbook/specs/control-flows.pw.js b/e2e/binance-orderbook/specs/control-flows.pw.js index a1cec89..46cabc2 100644 --- a/e2e/binance-orderbook/specs/control-flows.pw.js +++ b/e2e/binance-orderbook/specs/control-flows.pw.js @@ -264,6 +264,73 @@ test('a complete ladder submits the planned five native orders and restores cont expect(submissions.every((event) => event.action === '开多')).toBe(true); expect(submissions.every((event) => Number(event.price) < 81.1)).toBe(true); expect(submissions.every((event) => Number(event.quantity) > 0)).toBe(true); + const events = (await readFixtureState(page)).events; + for (let index = 0; index < submissions.length - 1; index += 1) { + const nextInputWrite = events.find( + (event) => event.type === 'trade-input-written' && event.at > submissions[index].at, + ); + expect(nextInputWrite.at - submissions[index].at).toBeLessThan(700); + } + expect(errors).toEqual([]); +}); + +test('a late toast from the previous order cannot acknowledge the next order', async ({ page }) => { + const scenario = createCancelScenario({ + ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, + host: { + submitFeedbackDelayMs: 500, + submitApiResponseDelayMsByOrder: [20, 700, 20, 20, 20], + }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + const panel = page.locator(PANEL_SELECTOR); + + await panel.getByRole('button', { name: '阶梯开多' }).click(); + await expect(panel.locator('#jh-binance-ladder-status')).toHaveText( + '阶梯开多已完成 · 已挂 5/5 笔', + { timeout: 12_000 }, + ); + + const events = (await readFixtureState(page)).events; + const submissions = events.filter((event) => event.type === 'order-submitted'); + expect(submissions).toHaveLength(5); + for (let index = 0; index < submissions.length - 1; index += 1) { + const acknowledgement = events.find( + (event) => event.type === 'order-submit-api-success' + && event.submitSequence === submissions[index].submitSequence, + ); + expect(acknowledgement.at).toBeLessThan(submissions[index + 1].at); + } + expect(errors).toEqual([]); +}); + +test('ladder waits for the native submit button to leave its busy state', async ({ page }) => { + const scenario = createCancelScenario({ + ui: { tradeMode: 'OPEN', orderbookPrecision: '0.1' }, + host: { + submitButtonBusyMs: 450, + submitButtonBusyAttribute: 'aria-busy', + submitButtonClearsInputsWhenReady: true, + }, + }); + const { errors } = await openUserscriptScenario(page, scenario); + const panel = page.locator(PANEL_SELECTOR); + + await panel.getByRole('button', { name: '阶梯开多' }).click(); + await expect(panel.locator('#jh-binance-ladder-status')).toHaveText( + '阶梯开多已完成 · 已挂 5/5 笔', + { timeout: 12_000 }, + ); + + const state = await readFixtureState(page); + const submissions = state.events.filter((event) => event.type === 'order-submitted'); + expect(submissions).toHaveLength(5); + expect(submissions.every((event) => Number(event.price) > 0)).toBe(true); + expect(submissions.every((event) => Number(event.quantity) > 0)).toBe(true); + expect(state.events.filter((event) => event.type === 'order-submit-while-busy')).toEqual([]); + for (let index = 1; index < submissions.length; index += 1) { + expect(submissions[index].at - submissions[index - 1].at).toBeGreaterThanOrEqual(450); + } expect(errors).toEqual([]); }); diff --git a/scripts/binance-orderbook-trade.user.js b/scripts/binance-orderbook-trade.user.js index 70ac178..0524b2b 100644 --- a/scripts/binance-orderbook-trade.user.js +++ b/scripts/binance-orderbook-trade.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.147 +// @version 2.7.148 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -2360,7 +2360,6 @@ LOCAL_LADDER_OPEN_STEP_KEY, LOCAL_LADDER_CLOSE_STEP_KEY ]; - const LADDER_ORDER_DELAY_MS = 520; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; const LADDER_SUBMIT_POLL_MS = 80; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; @@ -4061,7 +4060,28 @@ if (!button) return false; const text = (button.textContent || "").toLowerCase(); const cls = String(button.className || "").toLowerCase(); - return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]'); + return button.disabled || button.getAttribute("aria-disabled") === "true" || button.getAttribute("aria-busy") === "true" || button.getAttribute("data-loading") === "true" || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes("loading") || !!button.querySelector('[class*="loading"], [class*="spinner"], [aria-busy="true"]'); + } + async function waitForReadyLadderSubmitButton(plan) { + const resolveReadyButton = () => { + const candidate = plan.spec.buttonGetter(); + return candidate && !isSubmitButtonBusy(candidate) ? candidate : null; + }; + const button = await waitForTradeActionButtonFrameState( + document, + resolveReadyButton, + isVisibleElement, + TRADE_ACTION_BUTTON_READY_TIMEOUT_MS + ); + if (button) return button; + const currentButton = plan.spec.buttonGetter(); + if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { + throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); + } + if (isSubmitButtonBusy(currentButton)) { + throw new Error(`${plan.spec.label}按钮持续处理中,已停止`); + } + throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); } function readVisibleOrderFeedbackEntries() { const selectors = [ @@ -4137,12 +4157,11 @@ } const capturedApiSuccessesNow = readLadderSubmitApiSuccesses(submitCaptureId); if (capturedApiSuccessesNow.length === 1) return; - if (acknowledgement.status === "success") return; const busy = isSubmitButtonBusy(button); if (busy) sawBusy = true; await delay(LADDER_SUBMIT_POLL_MS); } - const settleHint = sawBusy ? "按钮已恢复但未收到明确成功反馈" : "未观察到提交按钮状态变化"; + const settleHint = sawBusy ? "按钮已恢复但未捕获当前下单 API 成功响应" : "未捕获当前下单 API 成功响应"; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } async function executeLadderPlan(plan, progress, abortSignal = null) { @@ -4163,6 +4182,10 @@ throwIfAborted(abortSignal); assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); + await waitForReadyLadderSubmitButton(plan); + throwIfAborted(abortSignal); + assertLadderExecutionContext(plan); + assertLadderMakerPrice(plan, order.price); const currentPriceInput = findPriceInput(); const currentQtyInput = findQtyInput(); if (!currentPriceInput || !currentQtyInput) throw new Error("执行中价格或数量输入框丢失"); @@ -4176,20 +4199,18 @@ const submittedPrice = synchronizedInputs.submittedPrice; assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = await waitForTradeActionButtonFrameState( - document, - plan.spec.buttonGetter, - isVisibleElement, - TRADE_ACTION_BUTTON_READY_TIMEOUT_MS - ); + const button = await waitForReadyLadderSubmitButton(plan); throwIfAborted(abortSignal); - if (!button) { - const currentButton = plan.spec.buttonGetter(); - if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { - throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); - } - throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); - } + assertSubmittedPriceMatchesExpectedPrice( + order.price, + findPriceInput()?.value || "", + "计划价" + ); + assertSubmittedQtyMatchesExpectedQty( + order.qty, + findQtyInput()?.value || "", + "计划量" + ); if (!CFG.SAFE_MODE) { const previousFeedback = takeOrderFeedbackSnapshot(); const submitCaptureId = beginLadderSubmitResponseCapture(); @@ -4233,7 +4254,6 @@ done++; recordLadderSubmittedOrder(progress); setLadderStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`); - await delay(LADDER_ORDER_DELAY_MS); throwIfAborted(abortSignal); } return { done, repriceAttempts, lastRepriceApiErrorCode }; diff --git a/src/binance-orderbook-trade/index.user.js b/src/binance-orderbook-trade/index.user.js index 51fbc1e..8830e48 100644 --- a/src/binance-orderbook-trade/index.user.js +++ b/src/binance-orderbook-trade/index.user.js @@ -3,7 +3,7 @@ // @namespace binance.orderbook.trade // @icon data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E // @icon64 data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2064%2064%22%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20rx%3D%2214%22%20fill%3D%22%23f0b90b%22%2F%3E%3Ctext%20x%3D%2232%22%20y%3D%2249%22%20text-anchor%3D%22middle%22%20font-family%3D%22Arial%2C%20sans-serif%22%20font-size%3D%2242%22%20font-weight%3D%22800%22%20fill%3D%22%23111827%22%3EJ%3C%2Ftext%3E%3C%2Fsvg%3E -// @version 2.7.147 +// @version 2.7.148 // @author jackhai9 // @description 单击订单簿价格,按当前开仓/平仓 tab 自动填数量并执行下单,内置数量倍率面板 // @match https://www.binance.com/*/futures/* @@ -264,7 +264,6 @@ import { LOCAL_LADDER_OPEN_STEP_KEY, LOCAL_LADDER_CLOSE_STEP_KEY, ]; - const LADDER_ORDER_DELAY_MS = 520; const LADDER_SUBMIT_ACK_TIMEOUT_MS = 3500; const LADDER_SUBMIT_POLL_MS = 80; const LADDER_ACTION_FEEDBACK_MIN_MS = 240; @@ -2363,6 +2362,7 @@ import { return ( button.disabled || button.getAttribute('aria-disabled') === 'true' || + button.getAttribute('aria-busy') === 'true' || button.getAttribute('data-loading') === 'true' || includesBinancePageText(text, BINANCE_PAGE_TEXT.submitBusy) || cls.includes('loading') || @@ -2370,6 +2370,29 @@ import { ); } + async function waitForReadyLadderSubmitButton(plan) { + const resolveReadyButton = () => { + const candidate = plan.spec.buttonGetter(); + return candidate && !isSubmitButtonBusy(candidate) ? candidate : null; + }; + const button = await waitForTradeActionButtonFrameState( + document, + resolveReadyButton, + isVisibleElement, + TRADE_ACTION_BUTTON_READY_TIMEOUT_MS, + ); + if (button) return button; + + const currentButton = plan.spec.buttonGetter(); + if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { + throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); + } + if (isSubmitButtonBusy(currentButton)) { + throw new Error(`${plan.spec.label}按钮持续处理中,已停止`); + } + throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); + } + function readVisibleOrderFeedbackEntries() { const selectors = [ '[role="alert"]', @@ -2460,7 +2483,6 @@ import { } const capturedApiSuccessesNow = readLadderSubmitApiSuccesses(submitCaptureId); if (capturedApiSuccessesNow.length === 1) return; - if (acknowledgement.status === 'success') return; const busy = isSubmitButtonBusy(button); if (busy) sawBusy = true; @@ -2468,7 +2490,7 @@ import { await delay(LADDER_SUBMIT_POLL_MS); } - const settleHint = sawBusy ? '按钮已恢复但未收到明确成功反馈' : '未观察到提交按钮状态变化'; + const settleHint = sawBusy ? '按钮已恢复但未捕获当前下单 API 成功响应' : '未捕获当前下单 API 成功响应'; throw new Error(`未收到明确${label}成功反馈(${settleHint}),已停止;请核对当前委托/历史成交`); } @@ -2492,6 +2514,11 @@ import { assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, order.price); + await waitForReadyLadderSubmitButton(plan); + throwIfAborted(abortSignal); + assertLadderExecutionContext(plan); + assertLadderMakerPrice(plan, order.price); + const currentPriceInput = findPriceInput(); const currentQtyInput = findQtyInput(); if (!currentPriceInput || !currentQtyInput) throw new Error('执行中价格或数量输入框丢失'); @@ -2507,20 +2534,18 @@ import { assertLadderExecutionContext(plan); assertLadderMakerPrice(plan, submittedPrice); - const button = await waitForTradeActionButtonFrameState( - document, - plan.spec.buttonGetter, - isVisibleElement, - TRADE_ACTION_BUTTON_READY_TIMEOUT_MS, - ); + const button = await waitForReadyLadderSubmitButton(plan); throwIfAborted(abortSignal); - if (!button) { - const currentButton = plan.spec.buttonGetter(); - if (!currentButton || !currentButton.isConnected || !isVisibleElement(currentButton)) { - throw new Error(`${plan.spec.label}按钮尚未渲染完成,已停止`); - } - throw new Error(`${plan.spec.label}按钮当前不可点击,已停止`); - } + assertSubmittedPriceMatchesExpectedPrice( + order.price, + findPriceInput()?.value || '', + '计划价', + ); + assertSubmittedQtyMatchesExpectedQty( + order.qty, + findQtyInput()?.value || '', + '计划量', + ); if (!CFG.SAFE_MODE) { const previousFeedback = takeOrderFeedbackSnapshot(); @@ -2565,7 +2590,6 @@ import { done++; recordLadderSubmittedOrder(progress); setLadderStatus(`${plan.spec.label}已挂 ${done}/${plan.orders.length} 笔`); - await delay(LADDER_ORDER_DELAY_MS); throwIfAborted(abortSignal); } return { done, repriceAttempts, lastRepriceApiErrorCode }; diff --git a/test/unit/binance-orderbook-trade/source-regressions.test.js b/test/unit/binance-orderbook-trade/source-regressions.test.js index 2c4e479..06690bd 100644 --- a/test/unit/binance-orderbook-trade/source-regressions.test.js +++ b/test/unit/binance-orderbook-trade/source-regressions.test.js @@ -146,10 +146,17 @@ test('trade mode and Post Only switches wait for observed state instead of fixed test('ladder execution waits for the current semantic action button before every submit', () => { const executeBody = readFunctionBody('executeLadderPlan'); + const readyButtonBody = readFunctionBody('waitForReadyLadderSubmitButton'); - assert.match(executeBody, /await waitForTradeActionButtonFrameState/); - assert.match(executeBody, /plan\.spec\.buttonGetter/); + assert.match(readyButtonBody, /await waitForTradeActionButtonFrameState/); + assert.match(readyButtonBody, /plan\.spec\.buttonGetter/); assert.doesNotMatch(executeBody, /const button = plan\.spec\.buttonGetter\(\);\s*if/); + assert.match(readyButtonBody, /isSubmitButtonBusy/); + assert.doesNotMatch(source, /LADDER_ORDER_DELAY_MS/); + assert.match(executeBody, /await waitForReadyLadderSubmitButton\(plan\)[\s\S]*syncTradeInputs/); + assert.match(executeBody, /syncTradeInputs[\s\S]*await waitForReadyLadderSubmitButton\(plan\)/); + assert.match(executeBody, /assertSubmittedPriceMatchesExpectedPrice[\s\S]*button\.click\(\)/); + assert.match(executeBody, /assertSubmittedQtyMatchesExpectedQty[\s\S]*button\.click\(\)/); }); test('trade input synchronization confirms live controlled values instead of sleeping', () => { @@ -302,6 +309,7 @@ test('open and close ladders reprice only remaining orders after explicit maker assert.match(acknowledgementBody, /isPostOnlyMakerRejectionFeedback\(pendingFailure\.message\)/); assert.match(acknowledgementBody, /createLadderMakerPriceConflictError\(pendingFailure\.message\)/); assert.match(acknowledgementBody, /capturedApiSuccessesNow\.length === 1/); + assert.doesNotMatch(acknowledgementBody, /acknowledgement\.status === 'success'/); assert.doesNotMatch(source, /LADDER_SUBMIT_API_CODE_GRACE_MS/); const repriceBody = readFunctionBody('refreshRemainingLadderOrders'); @@ -318,6 +326,9 @@ test('open and close ladders reprice only remaining orders after explicit maker const executeBody = readFunctionBody('executeLadderPlan'); assert.match(executeBody, /LADDER_REPRICE_MAX_ATTEMPTS/); assert.match(executeBody, /beginLadderSubmitResponseCapture\(\)/); + const readyButtonBody = readFunctionBody('waitForReadyLadderSubmitButton'); + assert.match(readyButtonBody, /!isSubmitButtonBusy\(candidate\)/); + assert.match(source, /button\.getAttribute\('aria-busy'\) === 'true'/); assert.match(executeBody, /endLadderSubmitResponseCapture\(submitCaptureId\)/); assert.match(executeBody, /waitForOrderSubmitAcknowledgement\([\s\S]*plan\.spec\.mode/); assert.match(executeBody, /refreshRemainingLadderOrders\(plan,\s*done\)/);