From 85a4e168b340bb91c154f0e1180d22616114e480 Mon Sep 17 00:00:00 2001 From: Robert DeLuca Date: Tue, 10 Nov 2026 02:05:20 -0600 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20native=20input=20for=20re?= =?UTF-8?q?al=20app=20workflows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve trusted touch, wheel, pointer and keyboard behavior through the existing managed browser boundary. Forward mobile context settings and honor Fetch credential omission so API fixtures do not change the browser session. --- docs/api.md | 16 +++- docs/browsers.md | 9 +- docs/guide.md | 10 ++- src/browser-control.js | 27 ++---- src/browser-session.js | 23 ++++- src/browser/app.js | 6 +- src/browser/interactor.js | 65 +++++++++++--- src/browser/native.js | 78 +++++++++++++++-- src/browser/shell.js | 2 +- src/browser/viewport.js | 4 +- src/cli-run.js | 3 +- src/config.js | 50 +++++++++-- src/native.js | 33 +++++++- src/providers/native.js | 56 ++++++++++++- src/providers/playwright.js | 5 +- tests/browser/framework.test.js | 28 +++++++ tests/browser/hardening.test.js | 2 +- tests/browser/interactor.test.js | 45 ++++++++++ tests/browser/viewport.test.js | 22 +++++ tests/capture/screenshots.test.js | 126 ++++++++++++++++++++++++++++ tests/core/cli.test.js | 75 +++++++++++++++++ tests/fixtures/app-server.js | 19 +++++ tests/fixtures/native-app-server.js | 14 +++- tests/native/workflows.test.js | 100 ++++++++++++++++++++++ types/config.d.ts | 5 +- types/index.d.ts | 18 ++++ 26 files changed, 774 insertions(+), 67 deletions(-) diff --git a/docs/api.md b/docs/api.md index 1583476..29853cc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,10 @@ Signatures: [browser types](../types/index.d.ts), [configuration](../types/confi | `UploadFile` | A browser file input fixture. `bytes` accepts a string, Blob or other BlobPart; `name` is the filename and `type` is an optional MIME type. | | `CustomInteractor` | The app-aware Interactor plus your named actions and assertions. Bind it inside each test so scopes and cancellation belong to that execution. | | `InteractorContext` | Interactor.js’s action/assertion context, available to custom definitions. Keep DOM access inside the supplied context. | +| `Interactor.nativeType` | Type text with the real keyboard into the focused app element. | +| `Interactor.nativeClick` | Click the resolved element, or an explicit viewport point, with the real mouse. Use ordinary click for synthetic DOM interaction. | +| `Interactor.nativeTap` | Tap the resolved element, or an explicit viewport point, with real touch input. Requires managed touch support. | +| `Interactor.nativeWheel` | Scroll with real wheel deltas beneath the current pointer. Move or hover the pointer first when targeting a scrollable panel. | ## App and screenshots @@ -56,8 +60,8 @@ Signatures: [browser types](../types/index.d.ts), [configuration](../types/confi | --- | --- | | `Application` | Navigate and inspect your app from a test. Tests have their own context and read the app DOM directly. Fresh documents don't reset browser storage or backend data. | | `Application.goto` | Navigate to a same-origin app path and wait for its document bridge. Then use Interactor to wait for the UI you care about. | -| `Application.request` | Fetch a same-origin URL using the browser session and test cancellation signal. Returns a standard Response. Use this for your app's test-only fixture endpoints; keep database factories in your project. | -| `Application.setViewport` | Resize the actual managed viewport and wait for layout. Each dimension must be 1–8192 CSS pixels. Manual runs require the requested dimensions to already match. | +| `Application.request` | Fetch options pass through, including `credentials: 'omit'` for API setup that should not change the browser session. Same-origin cookies are included by default. Dyno owns the cancellation signal. | +| `Application.setViewport` | Resize the actual managed viewport and wait for layout. Each dimension must be 1–16384 CSS pixels. Manual runs require the requested dimensions to already match. | | `Application.screenshot` | Save an exact PNG of the app, excluding the reporter. Full-page and element captures are supported. Required capture fails when unavailable; explicitly optional capture can skip. | | `Application.pause` | Open the reporter and suspend the execution deadline until Continue. Requires `--ui` or `--manual`. App timers and network work keep running; cleanup cannot pause. | | `Application.document` | Read the current app document after navigation is ready. Resolve elements again after navigation. | @@ -93,6 +97,14 @@ Signatures: [browser types](../types/index.d.ts), [configuration](../types/confi | `NativeHoverOptions` | Optionally choose a point inside the target element. Without it, Dyno chooses a visible point that receives pointer input. | | `NativeDialogOptions` | Accept or dismiss the next dialog opened by the callback. Optionally require a message and supply prompt text. | | `NativeConsoleMessage` | A captured console entry with its sequence ID, type and text. | +| `NativeBrowser.type` | Type text with the real keyboard into the focused element. Prefer `I.nativeType` when composing native keyboard workflows. | +| `NativeBrowser.click` | Click a connected element or a point in app viewport coordinates with the real mouse. Ordinary clicks stay in Interactor. | +| `NativeBrowser.tap` | Tap a connected element or viewport point with a real touch event. Requires a managed context with `browser.hasTouch: true`. | +| `NativeBrowser.wheel` | Send finite wheel deltas in CSS pixels under the current pointer. Mobile WebKit does not support wheel input. | +| `NativeBrowser.setColorScheme` | Choose the app system color scheme: light, dark, or no-preference. Cleanup restores the original preference. | +| `NativeBrowser.blockExternalRequests` | Block app-page HTTP requests outside the gateway origin. Data and blob URLs remain available. Does not cover service workers or WebSockets; resets between tests. | +| `NativeBrowser.blockedRequests` | Read an immutable list of up to 1,000 recent URLs blocked by the external-request guard. | +| `NativeWheel` | Horizontal and vertical wheel deltas in CSS pixels. Negative values scroll left or up. | ## Timings diff --git a/docs/browsers.md b/docs/browsers.md index 55cc2e5..fd16bb8 100644 --- a/docs/browsers.md +++ b/docs/browsers.md @@ -13,6 +13,8 @@ npx dyno --browser webkit Runs never download browsers. On Linux, `dyno install --with-deps` also installs [system dependencies](https://playwright.dev/docs/browsers#install-system-dependencies) and may need administrator access. Use `dyno browsers` to see downloaded builds and paths. +Headless Chromium uses Playwright's default headless shell. On macOS, Chromium `--ui`, Chrome, Edge and custom Chromium executables reject captures taller than 16,384 physical pixels. Full Chromium can repeat content beyond that height while reporting valid dimensions. Use default headless Chromium for long-page images. This is a conservative support limit; some custom builds may work beyond it. + ## Choose a browser | Browser | How to run | @@ -36,15 +38,18 @@ Use `app.native.capabilities` to choose browser-specific coverage explicitly. Re | Feature | Chromium / Chrome / Edge | Firefox | WebKit | Manual URL | | --- | --- | --- | --- | --- | | Native keys and pointer | Yes | Yes | Yes | Unavailable | +| Wheel | Yes | Yes | Desktop contexts only | Unavailable | +| Touch (`browser.hasTouch`) | Yes | Yes | Yes | Unavailable | +| App HTTP request guard | Yes | Yes | Yes | Unavailable | | Dialogs and browser console | Yes | Yes | Yes | Unavailable | -| Reduced motion | Yes | Yes | Yes | Unavailable | +| Color scheme and reduced motion | Yes | Yes | Yes | Unavailable | | Clipboard permission grants | Read/write | Unavailable | Read only | Unavailable | | Virtual authenticator | Yes | Unavailable | Unavailable | Unavailable | | App network offline | Yes | Unavailable | Unavailable | Unavailable | Native state resets between tests. Cookies, storage, and backend cleanup remain your project's responsibility. Chromium offline keeps companion HTTP connected, but can change its `navigator.onLine` too. -Set `browser.locale` and `browser.timezoneId` when creating managed contexts. `browser.deviceScaleFactor` accepts 1, 2, or 3. A mobile-sized viewport still uses a desktop browser; it doesn't emulate a phone's input or OS. +Set `browser.locale` and `browser.timezoneId` when creating managed contexts. `browser.deviceScaleFactor` accepts finite values from 1 to 3, including fractional ratios. Set `browser.hasTouch` for touch input, `browser.isMobile` for mobile viewport behavior, and `browser.userAgent` when needed. Firefox does not support `isMobile`. These are browser context settings; they do not reproduce a phone’s OS or hardware. ## Custom executables diff --git a/docs/guide.md b/docs/guide.md index 409fc19..2fdeaff 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -91,7 +91,7 @@ await I.find(/Contact saved/).rendered(); await I.find('$(input[name=email])').value('rob@example.com'); ``` -`rendered()` checks displayed content, including below the fold. `visible()` also requires it to be in the viewport. `expectTimeout` sets the assertion deadline. +`rendered()` checks displayed content, including below the fold. `visible()` also requires it to be in the viewport. Assertions retry until they match, then continue immediately. `expectTimeout` sets the assertion deadline. For changing browser state, use an assertion callback: @@ -263,7 +263,9 @@ Dyno gives each test 10 seconds, including hooks and document startup. Interacto | `runTimeout` | `60000` | Headless browser opening and results deadline, after app readiness | | `host`, `port` | `127.0.0.1`, `7357` | Gateway address; port 0 chooses a free port | | `browser.name`, `path`, `args` | `chromium`, Playwright build, `[]` | Managed target, optional executable, and launch arguments | -| `browser.deviceScaleFactor` | `1` | Playwright pixel ratio: 1, 2, or 3 | +| `browser.deviceScaleFactor` | `1` | Playwright pixel ratio from 1 to 3 | +| `browser.isMobile`, `browser.hasTouch` | `false` | Emulate mobile viewport behavior and touch input | +| `browser.userAgent` | Browser default | Override the managed browser user agent | | `viewport` | Browser size; managed: 1280 × 800 | Required app viewport dimensions | | `reportDir` | `.dyno/reports` | Saved JSON, JUnit, HTML, and copied images per browser run | | `artifactsDir` | `.dyno/artifacts` | PNG output directory | @@ -618,6 +620,10 @@ await I.nativeMove({ x: 0, y: 0 }); These work with scopes and custom interactors. `nativePress` uses the focused element; it doesn't focus a selector for you. Hover scrolls to a visible point that receives pointer input. Stale or covered targets fail. After an action navigates, assert on the destination with Interactor. +`nativeClick` and `nativeTap` also accept a point in app viewport coordinates. Without a point, they resolve the scoped element locally. Tap requires `browser.hasTouch: true`. Use `nativeType(text)` after focusing a field when keyboard events themselves are part of the behavior. `nativeWheel({ deltaX, deltaY })` scrolls beneath the current pointer. + +Use `setColorScheme('dark' | 'light' | 'no-preference')` for system appearance. `blockExternalRequests()` blocks app-page HTTP requests outside the gateway origin and records up to 1,000 recent URLs; `blockedRequests()` returns them. It doesn't block WebSockets, service-worker traffic, or companion requests. Both settings reset between tests. + ### Handle a dialog ```js diff --git a/src/browser-control.js b/src/browser-control.js index 6bc65e4..b8ef224 100644 --- a/src/browser-control.js +++ b/src/browser-control.js @@ -88,6 +88,7 @@ function appImage(bytes, metrics, options) { throw problem(502, 'The browser did not return a supported PNG image.'); let width = bytes.readUInt32BE(16), height = bytes.readUInt32BE(20); + if (width < 1 || height < 1) throw problem(502, 'Screenshot has invalid dimensions.'); if (width * height > 64_000_000) throw problem( 422, @@ -107,22 +108,8 @@ function appImage(bytes, metrics, options) { 409, `Screenshot dimensions do not match the app document, viewport, and pixel ratio: expected ${Math.round(expected.width * pixelRatio)} × ${Math.round(expected.height * pixelRatio)}, received ${width} × ${height}.`, ); - let target = { - width: Math.round( - (options.clip?.width ?? (options.fullPage ? metrics.fullWidth : metrics.width)) * pixelRatio, - ), - height: Math.round( - (options.clip?.height ?? (options.fullPage ? metrics.fullHeight : metrics.height)) * - pixelRatio, - ), - }; - if (target.width < 1 || target.height < 1 || target.width > width || target.height > height) - throw problem(409, 'The app viewport does not fit in the captured browser.'); - if (target.width === width && target.height === height && options.mask.length === 0) - return { bytes, ...target, pixelRatio }; - let source = PNG.sync.read(bytes); - let image = new PNG(target); - PNG.bitblt(source, image, 0, 0, target.width, target.height, 0, 0); + if (options.mask.length === 0) return { bytes, width, height, pixelRatio }; + let image = PNG.sync.read(bytes); let color = Buffer.from(options.maskColor); for (let rectangle of options.mask) { if (rectangle.width === 0 || rectangle.height === 0) continue; @@ -139,7 +126,7 @@ function appImage(bytes, metrics, options) { for (let y = top; y < bottom; y++) image.data.fill(color, (y * image.width + left) * 4, (y * image.width + right) * 4); } - return { bytes: PNG.sync.write(image), ...target, pixelRatio }; + return { bytes: PNG.sync.write(image), width, height, pixelRatio }; } export function createBrowserControl({ artifactsDir, workerId = '0', onReplaced = () => {}, getRun, getView, getAttachment, onView, onCommand, onCommandResult, onConnection = () => {} }) { @@ -232,7 +219,7 @@ export function createBrowserControl({ artifactsDir, workerId = '0', onReplaced let invalidate = (runId, endingRun = false) => { contexts.delete(runId); for (let request of nativeRequests.values()) - if (request.runId === runId && (endingRun || request.kind !== 'key')) + if (request.runId === runId && (endingRun || !['key', 'click', 'tap'].includes(request.kind))) request.controller.abort(problem(409, 'The native input context ended.')); for (let record of records.values()) { if (record.runId !== runId || record.status === 'accepted') continue; @@ -612,7 +599,7 @@ export function createBrowserControl({ artifactsDir, workerId = '0', onReplaced if ( canceled.has(canceledKey) || current?.executionId !== input.context.executionId || - (native.kind !== 'key' && !sameContext(current, input.context)) + (!['key', 'click', 'tap'].includes(native.kind) && !sameContext(current, input.context)) ) throw problem(409, 'Native input finished after its test document ended.'); send(result); @@ -722,7 +709,7 @@ export function createBrowserControl({ artifactsDir, workerId = '0', onReplaced let work = (async () => { try { let image = appImage( - await session.capture(options, record.controller.signal), + await session.capture({ ...options, pixelHeight: Math.ceil(imageHeight * metrics.pixelRatio) }, record.controller.signal), metrics, options, ); diff --git a/src/browser-session.js b/src/browser-session.js index 7a8ff70..4cfc0ac 100644 --- a/src/browser-session.js +++ b/src/browser-session.js @@ -13,6 +13,8 @@ export async function resolveSession(browser, { headless = true } = {}) { throw new Error( `Unknown browser "${name}". Choose ${browserNames.join(', ')} or use --manual.`, ); + if (browser.isMobile === true && name === 'firefox') + throw new Error('browser.isMobile is not supported with Firefox.'); let path = browser.path; if (path) { path = resolve(path); @@ -51,6 +53,11 @@ export async function createBrowserPool(plan, { size = 1, signal, onExit = () => throw new Error('Browser pool size must be between 1 and 32.'); signal?.throwIfAborted(); let server; + let channel = browserChannel(plan.name); + let executable = plan.path ? { executablePath: plan.path } : { channel }; + let shell = plan.name === 'chromium' && plan.headless && !plan.path; + let limitedCaptureHeight = process.platform === 'darwin' && + ['chromium', 'chrome', 'edge'].includes(plan.name) && !shell; try { server = await browserEngine(plan.name).launchServer({ headless: plan.headless, args: plan.args, @@ -63,8 +70,7 @@ export async function createBrowserPool(plan, { size = 1, signal, onExit = () => 'dom.timeout.enable_budget_timer_throttling': false, }, } : {}), - ...(plan.path ? { executablePath: plan.path } : - browserChannel(plan.name) ? { channel: browserChannel(plan.name) } : {}), + ...executable, }); } catch (error) { let help = !plan.path && /executable.*doesn.t exist/i.test(error.message) ? ` Install its Playwright build with dyno install --browser ${plan.name}.` : ''; @@ -185,12 +191,21 @@ export async function createBrowserPool(plan, { size = 1, signal, onExit = () => }; let worker = Object.freeze({ identity, - capabilities: Object.freeze({ screenshot: true, fullPage: true, hostViewport: true, native: nativeCapabilities(plan.name) }), + capabilities: Object.freeze({ + screenshot: true, + fullPage: true, + hostViewport: true, + native: nativeCapabilities(plan.name, plan), + }), open: (url) => call('open', { url }), activate: () => call('activate'), retire: (url) => call('retire', { url }), resize: (size, operationSignal) => call('resize', size, operationSignal), - capture: async (options, operationSignal) => Buffer.from(await call('capture', options, operationSignal), 'base64'), + capture: async (options, operationSignal) => { + if (limitedCaptureHeight && options.pixelHeight > 16384) + throw new Error('This macOS Chromium provider does not support captures taller than 16384 physical pixels: it may return repeated content. Use Dyno’s default headless Chromium or another managed browser.'); + return Buffer.from(await call('capture', options, operationSignal), 'base64'); + }, native: (input, operationSignal) => call('native', input, operationSignal), resetNative: async (input, operationSignal) => { try { await call('resetNative', input, operationSignal); } diff --git a/src/browser/app.js b/src/browser/app.js index 8fbf212..4b3cabd 100644 --- a/src/browser/app.js +++ b/src/browser/app.js @@ -10,12 +10,14 @@ import { fixtureOperationName, snapshotJson, readFixtureResponse, fixtureRequest */ /** + * Fetch options pass through, including `credentials: 'omit'` for API setup that should not change the browser session. Same-origin cookies are included by default. Dyno owns the cancellation signal. + * * @api Application.request * Fetch a same-origin URL using the browser session and test cancellation signal. Returns a standard Response. Use this for your app's test-only fixture endpoints; keep database factories in your project. */ /** - * Resize the actual managed viewport and wait for layout. Each dimension must be 1–8192 CSS pixels. Manual runs require the requested dimensions to already match. + * Resize the actual managed viewport and wait for layout. Each dimension must be 1–16384 CSS pixels. Manual runs require the requested dimensions to already match. * * @api Application.setViewport */ @@ -564,7 +566,7 @@ export function createApp( if (url.origin !== location.origin) throw new Error('app.request requires a same-origin path.'); return measure('request', `${init.method ?? 'GET'} ${url.pathname}`, () => - fetch(url, { ...init, credentials: 'same-origin', signal }), + fetch(url, { credentials: 'same-origin', ...init, signal }), ); }, }); diff --git a/src/browser/interactor.js b/src/browser/interactor.js index dfcdeb4..3b62323 100644 --- a/src/browser/interactor.js +++ b/src/browser/interactor.js @@ -143,7 +143,7 @@ * await I.find('$(input[name=email])').value('rob@example.com'); * ``` * - * `rendered()` checks displayed content, including below the fold. `visible()` also requires it to be in the viewport. `expectTimeout` sets the assertion deadline. + * `rendered()` checks displayed content, including below the fold. `visible()` also requires it to be in the viewport. Assertions retry until they match, then continue immediately. `expectTimeout` sets the assertion deadline. * * For changing browser state, use an assertion callback: * @@ -291,7 +291,7 @@ Interactor.defineAction('click', function* click(selector) { Interactor.defineAction('fill', function* fill(value) { yield this.focus(); yield this.assert(({ $ }) => !$.disabled && !$.readOnly, '#{this} is disabled or read-only'); - yield ({ $, signal }) => { + return yield ({ $, signal }) => { signal?.throwIfAborted(); if (!['input', 'textarea'].includes($.localName)) throw new Error('fill requires an input or textarea'); @@ -302,14 +302,15 @@ Interactor.defineAction('fill', function* fill(value) { : window.HTMLInputElement.prototype; let setter = Object.getOwnPropertyDescriptor(prototype, 'value').set; setter.call($, String(value)); + // Input handlers can replace the control. Finish this action on its original target. + dispatch($, 'input', { + cancelable: false, + inputType: 'insertReplacementText', + data: String(value), + }, signal); + dispatch($, 'change', { cancelable: false }, signal); + return $; }; - yield this.trigger('input', { - cancelable: false, - inputType: 'insertReplacementText', - data: String(value), - }); - yield this.trigger('change', { cancelable: false }); - return ({ $ }) => $; }); Interactor.defineAction('upload', function* upload(...files) { @@ -341,10 +342,29 @@ Interactor.defineAction('nativePress', function* nativePress(key) { return yield ({ native }) => nativeBrowser(native).press(key); }); +Interactor.defineAction('nativeType', function* nativeType(text) { + return yield ({ native }) => nativeBrowser(native).type(text); +}); + Interactor.defineAction('nativeMove', function* nativeMove(point) { return yield ({ native }) => nativeBrowser(native).move(point); }); +for (let [name, operation] of [['nativeClick', 'click'], ['nativeTap', 'tap']]) { + Interactor.defineAction(name, function* nativePointer(point) { + if (point !== undefined) return yield ({ native }) => nativeBrowser(native)[operation](point); + yield this.assert.rendered(); + return yield async ({ $, native }) => { + await nativeBrowser(native)[operation]($); + return $; + }; + }); +} + +Interactor.defineAction('nativeWheel', function* nativeWheel(delta) { + return yield ({ native }) => nativeBrowser(native).wheel(delta); +}); + Interactor.defineAction('nativeHover', function* nativeHover(options) { yield this.assert.rendered(); return yield async ({ $, native }) => { @@ -418,6 +438,31 @@ export function createInteractor(app, signal, timeout) { root: () => app.document.body, signal, native: app.native, - assert: { timeout }, + // Retry mismatches; a match does not need an extra timer-based check. + assert: { timeout, reliability: 0 }, }); } + +/** + * Type text with the real keyboard into the focused app element. + * + * @api Interactor.nativeType + */ + +/** + * Click the resolved element, or an explicit viewport point, with the real mouse. Use ordinary click for synthetic DOM interaction. + * + * @api Interactor.nativeClick + */ + +/** + * Tap the resolved element, or an explicit viewport point, with real touch input. Requires managed touch support. + * + * @api Interactor.nativeTap + */ + +/** + * Scroll with real wheel deltas beneath the current pointer. Move or hover the pointer first when targeting a scrollable panel. + * + * @api Interactor.nativeWheel + */ diff --git a/src/browser/native.js b/src/browser/native.js index f46cf5b..616259c 100644 --- a/src/browser/native.js +++ b/src/browser/native.js @@ -12,6 +12,10 @@ * * These work with scopes and custom interactors. `nativePress` uses the focused element; it doesn't focus a selector for you. Hover scrolls to a visible point that receives pointer input. Stale or covered targets fail. After an action navigates, assert on the destination with Interactor. * + * `nativeClick` and `nativeTap` also accept a point in app viewport coordinates. Without a point, they resolve the scoped element locally. Tap requires `browser.hasTouch: true`. Use `nativeType(text)` after focusing a field when keyboard events themselves are part of the behavior. `nativeWheel({ deltaX, deltaY })` scrolls beneath the current pointer. + * + * Use `setColorScheme('dark' | 'light' | 'no-preference')` for system appearance. `blockExternalRequests()` blocks app-page HTTP requests outside the gateway origin and records up to 1,000 recent URLs; `blockedRequests()` returns them. It doesn't block WebSockets, service-worker traffic, or companion requests. Both settings reset between tests. + * * ## Handle a dialog * * ```js @@ -177,9 +181,9 @@ function receivesPointer(element, x, y) { return containsHit(element, hit); } -function hoverPoint(element, position, document) { +function pointerPoint(element, position, document) { if (element?.nodeType !== 1 || element.ownerDocument !== document || !element.isConnected) - throw new Error('Native hover needs a connected element from the current app document.'); + throw new Error('Native pointer input needs a connected element from the current app document.'); element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); let window = document.defaultView; let box = element.getBoundingClientRect(); @@ -193,7 +197,7 @@ function hoverPoint(element, position, document) { position.x >= box.width || position.y >= box.height ) - throw new Error('Native hover position must be a finite point inside the element.'); + throw new Error('Native pointer position must be a finite point inside the element.'); points = [{ x: box.left + position.x, y: box.top + position.y }]; } else { points = [...element.getClientRects()].flatMap((rect) => { @@ -225,7 +229,7 @@ function hoverPoint(element, position, document) { receivesPointer(element, x, y), ); if (!point) - throw new Error('Native hover target has no visible point that receives pointer input.'); + throw new Error('Native pointer target has no visible point that receives pointer input.'); return point; } @@ -245,7 +249,7 @@ export function createNative({ measure('native', name, () => { signal.throwIfAborted(); let normalized = normalizeNative(input, supported); - let allowBlank = !['key', 'pointer'].includes(normalized.kind); + let allowBlank = !['key', 'type', 'pointer', 'click', 'tap', 'wheel'].includes(normalized.kind); return operation( { operation: 'native', input: normalized }, () => { @@ -262,8 +266,16 @@ export function createNative({ select, ); }); + let pointerInput = (kind, target) => { + if (target?.nodeType === 1) + return invoke(`Native ${kind} element`, { kind, x: 0, y: 0 }, document => ({ + input: normalizeNative({ kind, ...pointerPoint(target, undefined, document) }, supported), + })); + return invoke(`Native ${kind} at ${target?.x}, ${target?.y}`, { kind, x: target?.x, y: target?.y }); + }; return Object.freeze({ capabilities: supported, + type: (text) => invoke('Type with native keyboard', { kind: 'type', text }), press: (key) => invoke(`Press ${key}`, { kind: 'key', key }), move: (point) => invoke(`Move pointer to ${point?.x}, ${point?.y}`, { @@ -271,6 +283,12 @@ export function createNative({ x: point?.x, y: point?.y, }), + click: (target) => pointerInput('click', target), + tap: (target) => pointerInput('tap', target), + wheel: (delta) => invoke('Scroll with native wheel', { kind: 'wheel', deltaX: delta?.deltaX, deltaY: delta?.deltaY }), + setColorScheme: (value) => invoke(`Set color scheme to ${value}`, { kind: 'color-scheme', value }), + blockExternalRequests: () => invoke('Block external app requests', { kind: 'block-external' }), + blockedRequests: () => invoke('Read blocked app requests', { kind: 'blocked-requests' }, undefined, urls => Object.freeze([...urls])), hover: (element, options = {}) => invoke('Hover element', { kind: 'pointer', x: 0, y: 0 }, (document) => { if ( @@ -280,7 +298,7 @@ export function createNative({ Object.keys(options).some((key) => key !== 'position') ) throw new Error('Native hover options only accept an optional position.'); - let point = hoverPoint(element, options.position, document); + let point = pointerPoint(element, options.position, document); return { input: normalizeNative({ kind: 'pointer', ...point }, supported) }; }), grantPermissions: (permissions) => @@ -332,3 +350,51 @@ export function createNative({ }), }); } + +/** + * Type text with the real keyboard into the focused element. Prefer `I.nativeType` when composing native keyboard workflows. + * + * @api NativeBrowser.type + */ + +/** + * Click a connected element or a point in app viewport coordinates with the real mouse. Ordinary clicks stay in Interactor. + * + * @api NativeBrowser.click + */ + +/** + * Tap a connected element or viewport point with a real touch event. Requires a managed context with `browser.hasTouch: true`. + * + * @api NativeBrowser.tap + */ + +/** + * Send finite wheel deltas in CSS pixels under the current pointer. Mobile WebKit does not support wheel input. + * + * @api NativeBrowser.wheel + */ + +/** + * Choose the app system color scheme: light, dark, or no-preference. Cleanup restores the original preference. + * + * @api NativeBrowser.setColorScheme + */ + +/** + * Block app-page HTTP requests outside the gateway origin. Data and blob URLs remain available. Does not cover service workers or WebSockets; resets between tests. + * + * @api NativeBrowser.blockExternalRequests + */ + +/** + * Read an immutable list of up to 1,000 recent URLs blocked by the external-request guard. + * + * @api NativeBrowser.blockedRequests + */ + +/** + * Horizontal and vertical wheel deltas in CSS pixels. Negative values scroll left or up. + * + * @api NativeWheel + */ diff --git a/src/browser/shell.js b/src/browser/shell.js index c2d0321..913d644 100644 --- a/src/browser/shell.js +++ b/src/browser/shell.js @@ -256,7 +256,7 @@ async function operate(current, message) { signal, ); signal.throwIfAborted(); - if (message.input.kind !== 'key') + if (!['key', 'click', 'tap'].includes(message.input.kind)) assertCurrent(current, message.documentId, revision, undefined, 'native input'); else if (resource !== current || current.finished) throw new Error('Native input finished after its test execution ended.'); diff --git a/src/browser/viewport.js b/src/browser/viewport.js index 9a5cc47..e15fc3b 100644 --- a/src/browser/viewport.js +++ b/src/browser/viewport.js @@ -1,12 +1,12 @@ export let defaultViewport = Object.freeze({ width: 1280, height: 800 }); -export function validViewport(value, limit = 8192) { +export function validViewport(value, limit = 16384) { return Boolean( value && ['width', 'height'].every((key) => Number.isInteger(value[key]) && value[key] > 0 && value[key] <= limit) ); } export function normalizeViewport(value) { - if (!validViewport(value)) throw new Error('App viewport dimensions must be integers between 1 and 8192 CSS pixels.'); + if (!validViewport(value)) throw new Error('App viewport dimensions must be integers between 1 and 16384 CSS pixels.'); return Object.freeze({ width: value.width, height: value.height }); } diff --git a/src/cli-run.js b/src/cli-run.js index 58f9a7d..2d8f1b3 100644 --- a/src/cli-run.js +++ b/src/cli-run.js @@ -257,7 +257,8 @@ export async function runTests(options, { signal, output }) { deadline?.removeEventListener('abort', expired); } } catch (error) { - endingError = error instanceof Error ? error : new Error(error?.message ?? String(error)); + let reason = runSignal.aborted ? runSignal.reason : error; + endingError = reason instanceof Error ? reason : new Error(reason?.message ?? String(reason)); endingError.message = launchKeys.reduce((message, key) => message.replaceAll(key, '[private launch]'), endingError.message); } finally { controller.abort(new Error('Dyno is shutting down.')); diff --git a/src/config.js b/src/config.js index d3cc2cc..019b247 100644 --- a/src/config.js +++ b/src/config.js @@ -39,7 +39,9 @@ * | `runTimeout` | `60000` | Headless browser opening and results deadline, after app readiness | * | `host`, `port` | `127.0.0.1`, `7357` | Gateway address; port 0 chooses a free port | * | `browser.name`, `path`, `args` | `chromium`, Playwright build, `[]` | Managed target, optional executable, and launch arguments | - * | `browser.deviceScaleFactor` | `1` | Playwright pixel ratio: 1, 2, or 3 | + * | `browser.deviceScaleFactor` | `1` | Playwright pixel ratio from 1 to 3 | + * | `browser.isMobile`, `browser.hasTouch` | `false` | Emulate mobile viewport behavior and touch input | + * | `browser.userAgent` | Browser default | Override the managed browser user agent | * | `viewport` | Browser size; managed: 1280 × 800 | Required app viewport dimensions | * | `reportDir` | `.dyno/reports` | Saved JSON, JUnit, HTML, and copied images per browser run | * | `artifactsDir` | `.dyno/artifacts` | PNG output directory | @@ -185,14 +187,47 @@ export function normalizeConfig(input, file, overrides = {}) { throw new Error('webServer.env must contain string values.'); } let browser = input.browser ?? {}; - keys(browser, ['name', 'path', 'args', 'deviceScaleFactor', 'locale', 'timezoneId'], 'browser'); + keys( + browser, + [ + 'name', + 'path', + 'args', + 'deviceScaleFactor', + 'locale', + 'timezoneId', + 'isMobile', + 'hasTouch', + 'userAgent', + ], + 'browser', + ); let name = overrides.browser ?? browser.name ?? 'chromium'; if (!browserNames.includes(name)) throw new Error( `Unknown managed browser "${name}". Choose ${browserNames.join(', ')} or use --manual for other browsers.`, ); - if (browser.deviceScaleFactor !== undefined && ![1, 2, 3].includes(browser.deviceScaleFactor)) - throw new Error('browser.deviceScaleFactor must be 1, 2, or 3.'); + if ( + browser.deviceScaleFactor !== undefined && + (!Number.isFinite(browser.deviceScaleFactor) || + browser.deviceScaleFactor < 1 || + browser.deviceScaleFactor > 3) + ) + throw new Error('browser.deviceScaleFactor must be a finite number from 1 to 3.'); + for (let key of ['isMobile', 'hasTouch']) + if (browser[key] !== undefined && typeof browser[key] !== 'boolean') + throw new Error(`browser.${key} must be a boolean.`); + if (browser.isMobile === true && name === 'firefox') + throw new Error('browser.isMobile is not supported with Firefox.'); + if ( + browser.userAgent !== undefined && + (!text(browser.userAgent) || + browser.userAgent.length > 1000 || + /[\r\n]/.test(browser.userAgent)) + ) + throw new Error( + 'browser.userAgent must be a non-empty string of at most 1000 characters without line breaks.', + ); if (browser.locale !== undefined) { try { if (!text(browser.locale)) throw new Error(); @@ -252,9 +287,14 @@ export function normalizeConfig(input, file, overrides = {}) { browser: Object.freeze({ name, ...(path ? { path } : {}), - ...(browser.deviceScaleFactor ? { deviceScaleFactor: browser.deviceScaleFactor } : {}), + ...(browser.deviceScaleFactor !== undefined + ? { deviceScaleFactor: browser.deviceScaleFactor } + : {}), ...(browser.locale ? { locale: browser.locale } : {}), ...(browser.timezoneId ? { timezoneId: browser.timezoneId } : {}), + ...(browser.isMobile !== undefined ? { isMobile: browser.isMobile } : {}), + ...(browser.hasTouch !== undefined ? { hasTouch: browser.hasTouch } : {}), + ...(browser.userAgent !== undefined ? { userAgent: browser.userAgent } : {}), args: Object.freeze([...(browser.args ?? [])]), }), viewport, diff --git a/src/native.js b/src/native.js index 09ef09d..b33ecad 100644 --- a/src/native.js +++ b/src/native.js @@ -1,12 +1,15 @@ let clipboard = Object.freeze(['clipboard-read', 'clipboard-write']); let none = Object.freeze([]); -export function nativeCapabilities(name = null) { +export function nativeCapabilities(name = null, plan = {}) { let chromium = ['chromium', 'chrome', 'edge'].includes(name); let managed = chromium || ['firefox', 'webkit'].includes(name); return Object.freeze({ keyboard: managed, pointer: managed, + wheel: managed && !(name === 'webkit' && plan.isMobile === true), + touch: managed && plan.hasTouch === true, + network: managed, permissions: chromium ? clipboard : name === 'webkit' @@ -22,12 +25,19 @@ export function nativeCapabilities(name = null) { let properties = Object.freeze({ key: ['kind', 'key'], + type: ['kind', 'text'], pointer: ['kind', 'x', 'y'], + click: ['kind', 'x', 'y'], + tap: ['kind', 'x', 'y'], + wheel: ['kind', 'deltaX', 'deltaY'], permissions: ['kind', 'permissions'], authenticator: ['kind'], 'remove-authenticator': ['kind'], offline: ['kind', 'offline'], motion: ['kind', 'value'], + 'color-scheme': ['kind', 'value'], + 'block-external': ['kind'], + 'blocked-requests': ['kind'], dialog: ['kind', 'action', 'message', 'text'], 'dialog-result': ['kind'], 'dialog-cancel': ['kind'], @@ -35,11 +45,18 @@ let properties = Object.freeze({ }); let requiredCapability = Object.freeze({ key: 'keyboard', + type: 'keyboard', pointer: 'pointer', + click: 'pointer', + wheel: 'wheel', + tap: 'touch', authenticator: 'authenticator', 'remove-authenticator': 'authenticator', offline: 'offline', motion: 'media', + 'color-scheme': 'media', + 'block-external': 'network', + 'blocked-requests': 'network', dialog: 'dialogs', 'dialog-result': 'dialogs', 'dialog-cancel': 'dialogs', @@ -73,12 +90,22 @@ export function normalizeNative(input, capabilities) { ) throw invalid('Native keyboard input needs a key or key combination of 1–100 characters.'); return Object.freeze({ kind: input.kind, key: input.key }); + case 'type': + if (typeof input.text !== 'string' || input.text.length > 10000 || input.text.includes('\0')) + throw invalid('Native typing needs text of at most 10,000 characters without null bytes.'); + return Object.freeze({ kind: input.kind, text: input.text }); case 'pointer': + case 'click': + case 'tap': if (![input.x, input.y].every((value) => Number.isFinite(value) && value >= 0)) throw invalid( 'Native pointer coordinates must be finite, nonnegative viewport coordinates.', ); return Object.freeze({ kind: input.kind, x: input.x, y: input.y }); + case 'wheel': + if (![input.deltaX, input.deltaY].every(Number.isFinite)) + throw invalid('Native wheel deltas must be finite numbers.'); + return Object.freeze({ kind: input.kind, deltaX: input.deltaX, deltaY: input.deltaY }); case 'permissions': { if ( !Array.isArray(input.permissions) || @@ -121,6 +148,10 @@ export function normalizeNative(input, capabilities) { if (!['reduce', 'no-preference'].includes(input.value)) throw invalid('Native reduced motion must be reduce or no-preference.'); return Object.freeze({ kind: input.kind, value: input.value }); + case 'color-scheme': + if (!['light', 'dark', 'no-preference'].includes(input.value)) + throw invalid('Native color scheme must be light, dark, or no-preference.'); + return Object.freeze({ kind: input.kind, value: input.value }); default: return Object.freeze({ kind: input.kind }); } diff --git a/src/providers/native.js b/src/providers/native.js index 9f96509..e673824 100644 --- a/src/providers/native.js +++ b/src/providers/native.js @@ -20,6 +20,8 @@ export function createNative({ context, page, capabilities, close }) { let client; let authenticator; let originalMotion; + let originalColorScheme; + let externalGuard; let dialog; let activeRequests = new Map(); let consoleId = 0; @@ -114,6 +116,10 @@ export function createNative({ context, page, capabilities, close }) { }); if (changed.has('motion')) await restore(() => page.emulateMedia({ reducedMotion: originalMotion })); + if (changed.has('color-scheme')) + await restore(() => page.emulateMedia({ colorScheme: originalColorScheme })); + if (externalGuard) await restore(() => page.unroute('**/*', externalGuard.handler)); + externalGuard = undefined; if (changed.has('permissions')) await restore(() => context.clearPermissions()); if (changed.has('authenticator')) await restore(async () => { @@ -127,6 +133,7 @@ export function createNative({ context, page, capabilities, close }) { client = undefined; authenticator = undefined; originalMotion = undefined; + originalColorScheme = undefined; changed = new Set(); owner = undefined; if (failures.length) { @@ -162,7 +169,7 @@ export function createNative({ context, page, capabilities, close }) { } catch (error) { if ( !after || - request.input.kind !== 'key' || + !['key', 'click', 'tap'].includes(request.input.kind) || !/execution context was destroyed|cannot find context|because of a navigation/i.test( error.message, ) @@ -174,7 +181,7 @@ export function createNative({ context, page, capabilities, close }) { let expected = { runId: request.runId, ...request.context }; if (!sameExecution(actual, expected)) throw new Error('The owned app page has moved to another test execution.'); - let navigation = after && request.input.kind === 'key'; + let navigation = after && ['key', 'click', 'tap'].includes(request.input.kind); if ( !navigation && (actual.documentId !== expected.documentId || @@ -190,9 +197,50 @@ export function createNative({ context, page, capabilities, close }) { case 'key': await page.keyboard.press(input.key); break; + case 'type': + await page.keyboard.type(input.text); + break; case 'pointer': await page.mouse.move(input.x, input.y); break; + case 'click': + await page.mouse.click(input.x, input.y); + break; + case 'tap': + await page.touchscreen.tap(input.x, input.y); + break; + case 'wheel': + await page.mouse.wheel(input.deltaX, input.deltaY); + break; + case 'color-scheme': + if (!changed.has('color-scheme')) { + originalColorScheme = await page.evaluate(() => + matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', + ); + assertActive(request); + } + changed.add('color-scheme'); + await page.emulateMedia({ colorScheme: input.value }); + break; + case 'block-external': { + if (externalGuard) break; + let origin = new URL(page.url()).origin; + let records = Object.freeze([]); + let handler = async (route) => { + let url = new URL(route.request().url()); + if (url.origin === origin || ['data:', 'blob:'].includes(url.protocol)) { + await route.fallback(); + return; + } + records = Object.freeze([...records, url.href].slice(-1000)); + await route.abort(); + }; + externalGuard = Object.freeze({ handler, read: () => records }); + await page.route('**/*', handler); + break; + } + case 'blocked-requests': + return externalGuard?.read() ?? Object.freeze([]); case 'permissions': changed.add('permissions'); await context.grantPermissions(input.permissions, { origin: new URL(page.url()).origin }); @@ -304,10 +352,10 @@ export function createNative({ context, page, capabilities, close }) { ) throw new Error('Native input needs the current run, execution, document, and viewport.'); let input = normalizeNative(request.input, capabilities); - if (input.kind === 'pointer' && (input.x >= binding.width || input.y >= binding.height)) + if (['pointer', 'click', 'tap'].includes(input.kind) && (input.x >= binding.width || input.y >= binding.height)) throw new Error('Native pointer coordinates must be inside the app viewport.'); request = { ...request, input }; - if (['key', 'pointer'].includes(input.kind)) await page.bringToFront(); + if (['key', 'type', 'pointer', 'click', 'tap', 'wheel'].includes(input.kind)) await page.bringToFront(); await check(request); assertActive(request); let next = { runId: request.runId, executionId: request.context.executionId }; diff --git a/src/providers/playwright.js b/src/providers/playwright.js index 2fb79a1..a134497 100644 --- a/src/providers/playwright.js +++ b/src/providers/playwright.js @@ -8,6 +8,9 @@ export async function open(browser, plan, viewport, onExit, onDiagnostic = () => deviceScaleFactor: plan.deviceScaleFactor ?? 1, ...(plan.locale ? { locale: plan.locale } : {}), ...(plan.timezoneId ? { timezoneId: plan.timezoneId } : {}), + ...(plan.isMobile !== undefined ? { isMobile: plan.isMobile } : {}), + ...(plan.hasTouch !== undefined ? { hasTouch: plan.hasTouch } : {}), + ...(plan.userAgent !== undefined ? { userAgent: plan.userAgent } : {}), }); try { context.on('requestfailed', (request) => { @@ -28,7 +31,7 @@ export async function open(browser, plan, viewport, onExit, onDiagnostic = () => let native = createNative({ context, page, - capabilities: nativeCapabilities(plan.name), + capabilities: nativeCapabilities(plan.name, plan), close: stop, }); return Object.freeze({ diff --git a/tests/browser/framework.test.js b/tests/browser/framework.test.js index c678ff3..02d476e 100644 --- a/tests/browser/framework.test.js +++ b/tests/browser/framework.test.js @@ -344,3 +344,31 @@ test('isolated app', async ({app,I}) => { assert.equal(result.code, 0, result.stderr + result.stdout); assert.equal(result.summary.passed, 1); }); + + +test('API setup can omit cookies without signing the browser in or changing its session', { timeout: 30000 }, async t => { + let fixture = await project(t, { + source: ` +describe('Request credentials', () => { + test('honors cookie omission for requests and responses', async ({app,I}) => { + await app.goto('/'); + let save = {method:'POST',body:JSON.stringify({name:'API fixture'})}; + let response = await app.request('/api/save', {...save,credentials:'omit'}); + await I.assert(() => response.ok); + let anonymous = await (await app.request('/api/cookie')).json(); + await I.assert(() => !anonymous.cookie.includes('saved=yes'), 'API setup accepted a cookie despite credentials: omit'); + await app.request('/api/save', save); + let signedIn = await (await app.request('/api/cookie')).json(); + await I.assert(() => signedIn.cookie.includes('saved=yes'), 'Default requests should share the browser session'); + let omitted = await (await app.request('/api/cookie', {credentials:'omit'})).json(); + await I.assert(() => !omitted.cookie.includes('saved=yes'), 'An omitted request sent the browser session cookie'); + await app.request('/api/logout', {method:'POST',credentials:'omit'}); + let retained = await (await app.request('/api/cookie')).json(); + await I.assert(() => retained.cookie.includes('saved=yes'), 'An omitted response cleared the browser session cookie'); + }); +});`, + }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stderr + result.stdout); + assert.equal(result.summary.passed, 1); +}); diff --git a/tests/browser/hardening.test.js b/tests/browser/hardening.test.js index 7a6559a..6d11edb 100644 --- a/tests/browser/hardening.test.js +++ b/tests/browser/hardening.test.js @@ -186,7 +186,7 @@ test('a closed stdout consumer stops owned resources without an uncaught pipe er }); -test('the browser deadline stops registration that never completes and releases the app', { timeout: 90000 }, async t => { +test('the browser deadline releases the app even when registration never completes', { timeout: 90000 }, async t => { let fixture = await project(t, { config: { runTimeout: 1500 }, source: ` await new Promise(() => {}); describe('Workflow', () => { test('never registered', () => {}); }); diff --git a/tests/browser/interactor.test.js b/tests/browser/interactor.test.js index f5a6280..7d8e985 100644 --- a/tests/browser/interactor.test.js +++ b/tests/browser/interactor.test.js @@ -294,3 +294,48 @@ test('published contact', async ({app,I}) => { assert.equal(result.code, 0, result.stdout + result.stderr); assert.equal(result.summary.passed, 1); }); + + +test('fill completes when selecting a date replaces the control during an HTTP update', { timeout: 30000 }, async t => { + let fixture = await project(t, { + source: ` +describe('Replacing date fields', () => { + test('selects and saves the new day once', async ({app,I}) => { + await app.goto('/remounting-date'); + await I.find('$(input[type=date])').then.fill('2022-09-29'); + await I.find('Image date').value('2022-09-29'); + await I.find('Day saved: 2022-09-29').rendered(); + let saved = await (await app.request('/api/lifecycle')).json(); + await I.assert(() => JSON.stringify(saved) === JSON.stringify(['day:2022-09-29'])); + }); +});`, + }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(result.summary.passed, 1); +}); + +test('ready local interactions finish while the test context timers are suspended', { timeout: 30000 }, async t => { + let fixture = await project(t, { source: ` + describe('Ready application', () => { + test('edits and reveals existing content without advancing timers', async ({ app, I }) => { + await app.goto('/interactor'); + await I.find('Reveal note').visible(); + let originalTimeout = globalThis.setTimeout; + globalThis.setTimeout = () => { throw new Error('The test context timers are suspended'); }; + try { + let contact = I.scope('$(#work)'); + await contact.find('Name').then.fill('Ready contact'); + await contact.find('Name').value('Ready contact'); + await I.find('Private note').not.visible(); + await I.click('Reveal note'); + await I.find('Private note').visible(); + } finally { + globalThis.setTimeout = originalTimeout; + } + }); + });` }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(result.summary.passed, 1); +}); diff --git a/tests/browser/viewport.test.js b/tests/browser/viewport.test.js index 207d64b..2f0c8d2 100644 --- a/tests/browser/viewport.test.js +++ b/tests/browser/viewport.test.js @@ -133,3 +133,25 @@ test('must not pretend to resize',()=>{throw new Error('Workflow started with th false ); }); + + +test('managed runs can capture a tall real viewport without clipping long application pages', { timeout: 30000 }, async t => { + let fixture = await project(t, { + config: { viewport: {width:800,height:600} }, + source: ` +describe('Tall viewport', () => { + test('resizes the actual app and preserves the entire image', async ({app,I}) => { + await app.goto('/visual'); + await app.setViewport({width:800,height:12000}); + await I.assert(() => app.window.innerWidth === 800 && app.window.innerHeight === 12000); + let image = await app.screenshot('Long application page'); + await I.assert(() => image.width === 800 && image.height === 12000); + await app.setViewport({width:800,height:600}); + await I.assert(() => app.window.innerHeight === 600); + }); +});`, + }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.equal(result.summary.passed, 1); +}); diff --git a/tests/capture/screenshots.test.js b/tests/capture/screenshots.test.js index d2ce5f0..1da0229 100644 --- a/tests/capture/screenshots.test.js +++ b/tests/capture/screenshots.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { PNG } from 'pngjs'; +import { chromium } from 'playwright-core'; import { project, exec } from '../helpers/project.js'; let name = process.env.DYNO_CAPTURE_BROWSER ?? 'chromium'; @@ -14,6 +15,54 @@ let browser = { ...(process.env.DYNO_CAPTURE_PATH ? { path: process.env.DYNO_CAPTURE_PATH } : {}), }; +test('full-page images preserve content beyond 16384 device pixels without resizing', managedOptions, async t => { + let colors = [[180, 40, 90], [20, 160, 80], [20, 60, 140], [210, 130, 20], + [90, 30, 160], [30, 170, 180], [190, 70, 30], [60, 110, 50]]; + let source = ` +describe('Long-page pixels', () => { + test('captures every part of the record', async ({app, I}) => { + await app.goto('/visual'); + app.document.documentElement.style.cssText = 'margin:0;padding:0;scrollbar-width:none'; + app.document.body.style.cssText = 'margin:0;padding:0;width:393px'; + let bands = ${JSON.stringify(colors)}.map(color => { + let band = app.document.createElement('section'); + band.style.cssText = 'height:1000px;background:rgb(' + color.join(',') + ')'; + return band; + }); + app.document.body.replaceChildren(...bands); + await app.screenshot('complete long record', {fullPage:true}); + await I.assert(() => app.window.innerWidth === 393 && app.window.innerHeight === 727 && app.window.scrollY === 0); + }); +});`; + let config = { browser: { ...browser, deviceScaleFactor: 2.75 }, viewport: { width: 393, height: 727 } }; + let fixture = await project(t, { config, source }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + let attachment = result.events.find(event => event.type === 'attachment').attachment; + let image = PNG.sync.read(await readFile(attachment.path)); + assert.ok(image.height > 16384); + assert.ok(Math.abs(image.width - 393 * 2.75) <= 1); + assert.ok(Math.abs(image.height - 8000 * 2.75) <= 1); + for (let [index, color] of colors.entries()) { + let offset = (Math.round((index * 1000 + 500) * 2.75) * image.width + 10) * 4; + assert.deepEqual([...image.data.subarray(offset, offset + 4)], [...color, 255], `Missing page content in band ${index + 1}`); + } + if (process.platform === 'darwin' && name === 'chromium') { + let unsupported = await fixture.run(['--browser-path', chromium.executablePath()]).completion; + assert.equal(unsupported.summary?.failed, 1, unsupported.stdout + unsupported.stderr); + assert.match(unsupported.events.find(event => event.type === 'test').error.message, /16384 physical pixels/); + assert.equal(unsupported.events.some(event => event.type === 'attachment'), false); + let fractional = await project(t, { + config: { ...config, browser: { ...browser, path: chromium.executablePath(), deviceScaleFactor: 16384.25 / 8000 } }, + source, + }); + let boundary = await fractional.run().completion; + assert.equal(boundary.summary?.failed, 1, boundary.stdout + boundary.stderr); + assert.match(boundary.events.find(event => event.type === 'test').error.message, /16384 physical pixels/); + assert.equal(boundary.events.some(event => event.type === 'attachment'), false); + } +}); + test( 'managed capture saves real app pixels after local actions and responsive resize', managedOptions, @@ -279,6 +328,83 @@ test('captures device pixels', async ({ app }) => { }, ); +test( + 'WebKit element capture preserves painted fractional device-pixel edges', + { ...managedOptions, skip: name !== 'webkit' }, + async (t) => { + let fixture = await project(t, { + config: { browser: { ...browser, deviceScaleFactor: 2.75 }, viewport: { width: 800, height: 600 } }, + source: ` +describe('Fractional element capture', () => { +test('saves every painted edge', async ({ app }) => { + await app.goto('/visual'); + app.document.documentElement.style.cssText = 'margin:0;padding:0;scrollbar-width:none;background:white'; + app.document.body.style.cssText = 'margin:0;padding:0;width:800px;height:600px;background:white'; + let element = app.document.createElement('div'); + element.style.cssText = 'position:absolute;box-sizing:border-box;left:11.125px;top:21.125px;width:30.75px;height:22.75px;background:rgb(255,240,100);border:1px solid;border-color:rgb(255,0,0) rgb(0,0,255) rgb(0,200,0) rgb(0,0,0)'; + app.document.body.replaceChildren(element); + await app.screenshot('fractional painted element', { element }); +}); +});`, + }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + let attachment = result.events.find(event => event.type === 'attachment').attachment; + let image = PNG.sync.read(await readFile(attachment.path)); + assert.deepEqual([image.width, image.height], [86, 64]); + assert.equal(attachment.width, image.width); + assert.equal(attachment.height, image.height); + assert.equal(attachment.pixelRatio, 2.75); + let pixel = (x, y) => [...image.data.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)]; + assert.notDeepEqual(pixel(85, 10), [255, 255, 255, 255], 'The rightmost painted column was lost'); + assert.notDeepEqual(pixel(10, 63), [255, 255, 255, 255], 'The bottom painted row was lost'); + }, +); + +test( + 'WebKit viewport, full-page and masked captures preserve fractional device-pixel bounds', + { ...managedOptions, skip: name !== 'webkit' }, + async (t) => { + let fixture = await project(t, { + config: { browser: { ...browser, deviceScaleFactor: 1.25 }, viewport: { width: 803, height: 601 } }, + source: ` +describe('Fractional page capture', () => { +test('keeps partial edge pixels while masking the app', async ({ app }) => { + await app.goto('/visual'); + app.document.documentElement.style.cssText = 'margin:0;padding:0;scrollbar-width:none;background:rgb(20,160,80)'; + app.document.body.style.cssText = 'margin:0;padding:0;width:803px;height:701px;background:rgb(20,160,80)'; + let secret = app.document.createElement('div'); + secret.style.cssText = 'position:absolute;left:20px;top:20px;width:20px;height:20px;background:rgb(200,10,20)'; + app.document.body.replaceChildren(secret); + await app.screenshot('fractional viewport'); + await app.screenshot('fractional full page', { fullPage: true }); + await app.screenshot('fractional masked viewport', { mask: [secret], maskColor: '#112233' }); +}); +});`, + }); + let result = await fixture.run().completion; + assert.equal(result.code, 0, result.stdout + result.stderr); + let attachments = result.events.filter(event => event.type === 'attachment').map(event => event.attachment); + assert.equal(attachments.length, 3); + let images = await Promise.all(attachments.map(async attachment => PNG.sync.read(await readFile(attachment.path)))); + for (let [index, image] of images.entries()) { + assert.deepEqual([image.width, image.height], [1004, index === 1 ? 877 : 752]); + assert.equal(attachments[index].width, image.width); + assert.equal(attachments[index].height, image.height); + assert.equal(attachments[index].pixelRatio, 1.25); + } + let pixel = (image, x, y) => [...image.data.subarray((y * image.width + x) * 4, (y * image.width + x) * 4 + 4)]; + let edge = pixel(images[0], 10, 751); + // WebKit on macOS uses partial alpha here; Linux paints the edge opaque. + assert.ok(edge[3] > 0, 'The viewport edge must retain painted pixels'); + assert.ok(edge[1] > edge[2] && edge[2] > edge[0], 'The viewport edge must keep its green background'); + assert.deepEqual(pixel(images[2], 10, 751), edge, 'Masking changed the partial viewport edge'); + assert.deepEqual(pixel(images[0], 37, 37), [200, 10, 20, 255]); + assert.deepEqual(pixel(images[2], 37, 37), [17, 34, 51, 255]); + assert.deepEqual(pixel(images[1], 10, 875), [20, 160, 80, 255]); + }, +); + test( 'a managed provider process crash fails clearly and releases owned resources', { ...managedOptions, skip: managedOptions.skip || process.platform === 'win32' }, diff --git a/tests/core/cli.test.js b/tests/core/cli.test.js index f493729..8d8f23e 100644 --- a/tests/core/cli.test.js +++ b/tests/core/cli.test.js @@ -6,6 +6,8 @@ import { once } from 'node:events'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { project, exec, repository, launch } from '../helpers/project.js'; +import { normalizeConfig } from '../../src/config.js'; +import { nativeCapabilities, normalizeNative } from '../../src/native.js'; let cli = join(repository, 'bin/dyno.js'); let eventsFrom = (stdout) => @@ -202,6 +204,15 @@ test('config typos and unsafe profile overrides fail before app startup', { { timeuot: 1000 }, { timeout: 2147483648 }, { browser: { name: 'netscape' } }, + { browser: { deviceScaleFactor: 0.99 } }, + { browser: { deviceScaleFactor: 3.01 } }, + { browser: { deviceScaleFactor: '2.5' } }, + { browser: { isMobile: 'true' } }, + { browser: { hasTouch: 1 } }, + { browser: { userAgent: '' } }, + { browser: { userAgent: 'agent\nforged' } }, + { browser: { userAgent: 'a'.repeat(1001) } }, + { browser: { name: 'firefox', isMobile: true } }, { browser: { locale: 'not_a_locale' } }, { browser: { timezoneId: 'Unknown/Nowhere' } }, { browser: { provider: 'playwright' } }, @@ -224,6 +235,70 @@ test('config typos and unsafe profile overrides fail before app startup', { } }); +test('config keeps fractional pixel ratios and managed context options immutable', () => { + let config = normalizeConfig( + { + browser: { + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + userAgent: 'Dyno Mobile Test', + }, + webServer: { url: 'http://localhost:3000' }, + }, + '/project/dyno.config.js', + ); + assert.deepEqual(config.browser, { + name: 'chromium', + deviceScaleFactor: 1.25, + isMobile: true, + hasTouch: true, + userAgent: 'Dyno Mobile Test', + args: [], + }); + assert.equal(Object.isFrozen(config.browser), true); + assert.equal(Object.isFrozen(config.browser.args), true); +}); + +test('native input validation keeps pointer, touch, media, and network capabilities explicit', () => { + let chromium = nativeCapabilities('chromium', { hasTouch: true }); + assert.equal(chromium.touch, true); + assert.equal(chromium.wheel, true); + assert.equal(nativeCapabilities('webkit', { isMobile: true }).wheel, false); + assert.equal(chromium.network, true); + assert.equal(nativeCapabilities('chromium').touch, false); + assert.equal(nativeCapabilities('firefox', { hasTouch: true }).touch, true); + assert.equal(nativeCapabilities(null, { hasTouch: true }).touch, false); + for (let name of ['chromium', 'firefox', 'webkit']) { + let capabilities = nativeCapabilities(name, { hasTouch: true }); + assert.equal(capabilities.network, true); + } + + for (let input of [ + { kind: 'type', text: 'Native text' }, + { kind: 'click', x: 8.5, y: 12 }, + { kind: 'tap', x: 8.5, y: 12 }, + { kind: 'wheel', deltaX: -30000, deltaY: 30000 }, + { kind: 'color-scheme', value: 'dark' }, + { kind: 'block-external' }, + { kind: 'blocked-requests' }, + ]) { + let normalized = normalizeNative(input, chromium); + assert.deepEqual(normalized, input); + assert.equal(Object.isFrozen(normalized), true); + } + + for (let [input, capabilities] of [ + [{ kind: 'type', text: 'bad\0text' }, chromium], + [{ kind: 'tap', x: 1, y: 1 }, { ...chromium, touch: false }], + [{ kind: 'wheel', deltaX: 0, deltaY: Infinity }, chromium], + [{ kind: 'wheel', deltaX: '1', deltaY: 1 }, chromium], + [{ kind: 'color-scheme', value: 'system' }, chromium], + [{ kind: 'block-external', enabled: true }, chromium], + ]) + assert.throws(() => normalizeNative(input, capabilities)); +}); + test('unsupported managed browsers fail before starting an app', { timeout: 10000 }, async (t) => { let fixture = await project(t); let result = await fixture.run(['--browser', 'safari']).completion; diff --git a/tests/fixtures/app-server.js b/tests/fixtures/app-server.js index 2d873c5..cd22e56 100644 --- a/tests/fixtures/app-server.js +++ b/tests/fixtures/app-server.js @@ -260,6 +260,25 @@ let server = http.createServer(async (request, response) => { response.end(JSON.stringify(lifecycle)); return; } + if (request.url === '/remounting-date') { + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end(`

Archive date

+
Choose a date + `); + return; + } if (request.url === '/api/logout') { response.writeHead(200, { 'Set-Cookie': 'saved=; Max-Age=0; HttpOnly; Path=/' }); response.end('signed out'); return; } if (request.url === '/api/crash') { response.end('stopping'); queueMicrotask(() => process.exit(9)); return; } if (request.url === '/missing-page' || request.url === '/unavailable-page') { diff --git a/tests/fixtures/native-app-server.js b/tests/fixtures/native-app-server.js index 7ca6905..dd26234 100644 --- a/tests/fixtures/native-app-server.js +++ b/tests/fixtures/native-app-server.js @@ -4,13 +4,14 @@ let records = []; let statusReads = 0; let mediaPreference; let queuedNative = Promise.withResolvers(), cancelledNative = Promise.withResolvers(); -let html = `Native fixture +let html = `Native fixture

Native fixture

@@ -20,10 +21,13 @@ body{font:16px system-ui;margin:24px}label{display:block;margin:12px 0} +
Hover target
Open destination