diff --git a/runtime/auth-broker/tests/helpers/native-fixture.js b/runtime/auth-broker/tests/helpers/native-fixture.js new file mode 100644 index 0000000..0bf1c09 --- /dev/null +++ b/runtime/auth-broker/tests/helpers/native-fixture.js @@ -0,0 +1,59 @@ +'use strict'; + +const assert = require('node:assert/strict'); + +// Only for intercepted test pages. A navigation response does not mean that the +// selected document is loaded or that Chrome has painted its native window. +async function settleFixture(connection) { + await connection.command('Page.bringToFront'); + const ready = await connection.evaluate(`new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('fixture render timed out')), 5000); + requestAnimationFrame(() => requestAnimationFrame(() => { + clearTimeout(timer); + resolve(document.readyState === 'complete' && document.visibilityState === 'visible'); + })); + })`); + assert.equal(ready, true, 'fixture must be loaded and visible before native interaction'); +} + +async function navigateFixture(connection, url, readyExpression) { + const navigation = await connection.command('Page.navigate', { url }); + assert.equal(navigation.errorText, undefined); + assert.ok(navigation.loaderId); + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + let ready = false; + try { + const { frameTree } = await connection.command('Page.getFrameTree'); + ready = frameTree.frame.loaderId === navigation.loaderId + && await connection.evaluate('document.readyState') === 'complete' + && await connection.evaluate(readyExpression); + } catch { /* A previous document may be destroyed while navigation commits. */ } + if (ready) return settleFixture(connection); + await new Promise(resolve => setTimeout(resolve, 30)); + } + assert.fail('requested fixture document did not load'); +} + +async function reportNativeFixture(connection) { + if (!connection) return; + try { + const state = await connection.evaluate(`(() => ({ + ready: document.readyState, visibility: document.visibilityState, + focused: document.hasFocus(), activeTag: document.activeElement?.tagName, + viewport: [innerWidth, innerHeight, outerWidth, outerHeight], + scroll: [scrollX, scrollY], scale: devicePixelRatio, + fields: Array.from(document.querySelectorAll('input')).map(element => ({ + type: element.type, filled: !!element.value, active: element === document.activeElement, + visible: element.offsetParent !== null, rect: element.getBoundingClientRect().toJSON(), + })), + buttons: Array.from(document.querySelectorAll('button')).map(element => ({ + type: element.type, visible: element.offsetParent !== null, + rect: element.getBoundingClientRect().toJSON(), + })), + }))()`); + console.error('Native fixture state:', JSON.stringify(state)); + } catch { /* Keep the original test failure when the target has closed. */ } +} + +module.exports = { navigateFixture, settleFixture, reportNativeFixture }; diff --git a/runtime/auth-broker/tests/paycom-assistance-continuation.test.js b/runtime/auth-broker/tests/paycom-assistance-continuation.test.js index b8e2f0b..19987a7 100644 --- a/runtime/auth-broker/tests/paycom-assistance-continuation.test.js +++ b/runtime/auth-broker/tests/paycom-assistance-continuation.test.js @@ -7,6 +7,7 @@ const path = require('node:path'); const { ChromeBrowserRuntime } = require('../src/browser-runtime'); const { CdpConnection, createTarget } = require('../src/cdp'); const { paycomAdapter, SECURITY_QUESTION_PATH, CLIENT_LANDING_PATH, SNAPSHOT } = require('../../../plugins/paycom/backend/auth/adapter'); +const { navigateFixture, settleFixture, reportNativeFixture } = require('./helpers/native-fixture'); test('CAPTCHA continuation stays on the original document and only submits unchanged retained PINs once', { timeout: 60000 }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'paycom-assistance-')); fs.chmodSync(root, 0o700); @@ -39,19 +40,7 @@ test('CAPTCHA continuation stays on the original document and only submits uncha .catch(error => { if (!request.url.includes('example.com')) interceptionError = error; }); }); await connection.command('Page.enable'); await connection.command('Fetch.enable', { patterns: [{ urlPattern: '*' }] }); - const load = async () => { - const navigation = await connection.command('Page.navigate', { url }); - for (let i = 0; i < 100; i++) { - try { - const { frameTree } = await connection.command('Page.getFrameTree'); - if (frameTree.frame.loaderId === navigation.loaderId - && await connection.evaluate('document.readyState') === 'complete' - && (await connection.evaluate(SNAPSHOT)).captchaPresent) return; - } catch {} - await new Promise(resolve => setTimeout(resolve, 30)); - } - assert.fail('fixture page did not load'); - }; + const load = () => navigateFixture(connection, url, `(${SNAPSHOT}).captchaPresent`); for (const scenario of ['empty_before', 'still_visible', 'reloaded', 'cleared', 'changed', 'completed']) { await load(); if (scenario === 'empty_before') await connection.evaluate('document.querySelector("#pin2").value=""; document.querySelector("#pin5").value=""'); @@ -63,6 +52,7 @@ test('CAPTCHA continuation stays on the original document and only submits uncha if (scenario !== 'still_visible') await connection.evaluate('document.querySelector("iframe").remove()'); if (scenario === 'cleared') await connection.evaluate('document.querySelector("#pin2").value=""'); if (scenario === 'changed') await connection.evaluate('document.querySelector("#pin2").value="different"'); + await settleFixture(connection); let resumed = 0; const operation = paycomAdapter.completeBrowserAssistance(browser, context, { loginOnly: true, resumeAuthentication: async () => { resumed++; return { status: 'authenticated' }; } }); @@ -78,5 +68,8 @@ test('CAPTCHA continuation stays on the original document and only submits uncha if (scenario !== 'empty_before') assert.equal(resumed, 0); if (interceptionError) throw interceptionError; } + } catch (error) { + await reportNativeFixture(connection); + throw error; } finally { connection?.close(); await browser?.close(); fs.rmSync(root, { recursive: true, force: true }); } }); diff --git a/runtime/auth-broker/tests/paycom-native-window.test.js b/runtime/auth-broker/tests/paycom-native-window.test.js index e96bde7..555cf31 100644 --- a/runtime/auth-broker/tests/paycom-native-window.test.js +++ b/runtime/auth-broker/tests/paycom-native-window.test.js @@ -8,6 +8,7 @@ const test = require('node:test'); const { ChromeBrowserRuntime, processGroupAlive } = require('../src/browser-runtime'); const { CdpConnection, createTarget } = require('../src/cdp'); const adapter = require('../../../plugins/paycom/backend/auth/adapter'); +const { navigateFixture, reportNativeFixture } = require('./helpers/native-fixture'); test('normal Chrome types exact PINs into locally intercepted forms and preserves the profile', { timeout: 60000 }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-native-window-')); @@ -54,7 +55,9 @@ test('normal Chrome types exact PINs into locally intercepted forms and preserve pair = indices; pending = new Promise((resolve, reject) => { resolvePost = resolve; rejectPost = reject; }); pending.catch(() => {}); - await connection.command('Page.navigate', { url }); + await navigateFixture(connection, url, + `document.querySelector('input[name="firstIndex"]')?.value === ${JSON.stringify(String(pair[0]))} + && document.querySelector('input[name="secondIndex"]')?.value === ${JSON.stringify(String(pair[1]))}`); const current = await adapter.waitForState(connection, 5000, new Set(['security_questions_required'])); await adapter.submitNativeChallenge(connection, credentials, current.snapshot.challenge, browser, AbortSignal.timeout(10000)); const form = await pending; @@ -81,6 +84,9 @@ test('normal Chrome types exact PINs into locally intercepted forms and preserve connection = await CdpConnection.connect(target2.webSocketDebuggerUrl); const cookies = (await connection.command('Network.getCookies', { urls: [url] })).cookies; assert.equal(cookies.some(cookie => cookie.name === 'fixture_cookie' && cookie.value === 'retained'), true); + } catch (error) { + await reportNativeFixture(connection); + throw error; } finally { connection?.close(); await browser?.close(); fs.rmSync(root, { recursive: true, force: true }); } diff --git a/tooling/ci-browser-smoke.js b/tooling/ci-browser-smoke.js index 11eff77..d35424c 100644 --- a/tooling/ci-browser-smoke.js +++ b/tooling/ci-browser-smoke.js @@ -11,7 +11,9 @@ async function main() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dispatch-ci-browser-')); fs.chmodSync(root, 0o700); let diagnostics = '', browser; - const runtime = new ChromeBrowserRuntime({ stateRoot: path.join(root, 'profiles'), directoryNetwork: false, + // This checks tool availability on a cold hosted runner, not startup latency. + // Production and the real browser tests retain their normal startup limits. + const runtime = new ChromeBrowserRuntime({ stateRoot: path.join(root, 'profiles'), directoryNetwork: false, startTimeoutMs: 60000, spawnImpl(command, args, options) { const stdio = [...options.stdio];stdio[2] = 'pipe'; const child = spawn(command, args, { ...options, stdio }); diff --git a/tooling/platform-dependencies.json b/tooling/platform-dependencies.json index d8a22f1..2f38568 100644 --- a/tooling/platform-dependencies.json +++ b/tooling/platform-dependencies.json @@ -1,9 +1,5 @@ { - "url": null, - "sha256": null, - "description": "Development CI builds an isolated bundle from the exact Core commit. Before production publication, pin the Core release asset URL and SHA-256 through a reviewed PR.", - "developmentSource": { - "repository": "dispatch-core", - "commit": "90e29d2940eac250b15042e336661562cff72fe1" - } + "url": "https://github.com/dillonlille/dispatch-core/releases/download/v0.0.1/platform-packages.tar.gz", + "sha256": "b233ad949d4bfc931e19ef7e988c3e15e109241e9aa479cbdeb54404ac013990", + "description": "Use the verified platform package bundle published with Core 0.0.1 for DSP checks, builds, and releases. Upgrade the URL and SHA-256 together through a reviewed PR." }