diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 911a1c1..137c915 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -2,7 +2,7 @@ name: Run Dyno tests description: Run one suite and retain its console and JUnit output. inputs: suite: - description: core, browser, execution, reporter, package, capture, or native. + description: core, browser, execution, project, reporter, package, capture, or native. required: true browser: description: Playwright engine for browser suites. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c46871..5a49a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: fail-fast: false matrix: engine: [chromium, firefox, webkit] - suite: [browser, execution, reporter, capture, native] + suite: [browser, execution, project, reporter, capture, native] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -95,7 +95,7 @@ jobs: fail-fast: false matrix: engine: [chromium, firefox, webkit] - suite: [browser, execution, reporter, capture, native] + suite: [browser, execution, project, reporter, capture, native] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/api.md b/docs/api.md index 1583476..40b10bd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -42,7 +42,7 @@ Signatures: [browser types](../types/index.d.ts), [configuration](../types/confi | `Interactor.scope` | Bind an independent root that resolves again after navigation. Create scopes before chaining; a missing root fails instead of searching elsewhere. | | `Interactor.fill` | Replace an editable input or textarea value and dispatch input/change events. Empty strings and numeric inputs work. Use upstream `type` when individual keystrokes matter. | | `Interactor.upload` | Select files and dispatch input/change events. Pass no files to clear the selection. File bytes can come from a project fixture endpoint. | -| `Interactor.nativePress` | Send a real key to the focused app element. Requires managed keyboard support; ordinary Interactor `press` uses synthetic events. | +| `Interactor.nativePress` | Send a real key to the focused app element when browser defaults matter, such as Tab navigation. Requires managed keyboard support. Use upstream `press` for app-defined keyboard handlers; it runs directly in the browser. | | `Interactor.nativeMove` | Move the native pointer in app viewport coordinates. Useful for moving away from a hovered element. | | `Interactor.nativeHover` | Move the native pointer over the resolved element. An optional position is relative to its bounding box in CSS pixels. | | `defineInteractor` | Define reusable component actions and assertions. Keep definitions in your app’s test support or beside the component, then bind them to the test’s `I`. Share definitions, never bound instances. | @@ -56,8 +56,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 +93,9 @@ 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.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. | ## Timings diff --git a/docs/architecture.md b/docs/architecture.md index c3fd1b6..aca597a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -14,7 +14,7 @@ Every command shares cancellation, including browser installation. Shutdown canc The exported `Interactor` and each test's `I` use the same app-aware class. Its public Interactor extension points supply app-realm events/files, form replacement, rendered assertions, and dynamic scopes. Scopes create independent contexts with the same assertion settings and signal; they resolve their roots again after navigation. Interactor.js still owns selectors, chaining, retries, and stability checks. Apps keep domain page objects and fixture I/O. -`defineInteractor({ actions, assertions })` registers native extensions on a private subclass with its own assertion class. Its returned function binds that definition to an existing Interactor and optional selector. Definitions never register methods on `I` or siblings. Binding and `scope()` share one context-copy path; neither caches app elements. The small subclass isolates native registration rather than introducing another interaction engine. +`defineInteractor({ actions, assertions })` registers actions and assertions through Interactor's public API on a private subclass with its own assertion class. Its returned function binds that definition to an existing Interactor and optional selector. Definitions never register methods on `I` or siblings. Binding and `scope()` share one context-copy path; neither caches app elements. The small subclass keeps registrations independent. ## App and test windows @@ -129,7 +129,7 @@ CLI lifecycle intervals use a separate session clock. Each report saves a runnin ## Native browser operations -Rare native keys, pointer movement, permissions, media, offline transport, and virtual authenticators use the existing runtime message, controller queue, authenticated gateway, and owned worker. Interactor resolves DOM targets locally. No exposed SDK binding, driver selector, or generic evaluation API enters the app. +Interactor owns clicks, typing, app keyboard handlers, and scrolling through local actions. The managed input surface is limited to real keys for browser defaults and pointer movement for CSS hover. Permissions, media, offline transport, and virtual authenticators use the same runtime message, controller queue, authenticated gateway, and owned worker. Interactor resolves DOM targets locally. No exposed SDK binding, driver selector, or generic evaluation API enters the app. Requests carry execution and document identity. The worker checks the original page bridge immediately before dispatch. Geometry stays tied to that document; keys may navigate within their execution. Cancellation rejects queued requests and late results, but cannot undo delivered input. The checks detect races rather than making browser input atomic. diff --git a/docs/browsers.md b/docs/browsers.md index 55cc2e5..7aef284 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,16 @@ 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 | +| 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/ci.md b/docs/ci.md index 2e0f6b6..a114d68 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -10,6 +10,7 @@ | Package | `npm run test:package` | Fresh tarball and checkout installs, init, and a real Chromium test on Node 26 | | Browser | `npm run test:browser -- firefox` | Real app, gateway, viewport, and process workflows in Chromium, Firefox, and WebKit on Linux and macOS, using Node 24 | | Execution | `npm run test:execution -- firefox` | Native concurrency, ownership, queue replay, seeded order, suite hooks, cleanup, pause, watch and replacement workflows in all three engines on Linux and macOS | +| Project | `npm run test:project -- firefox` | Project resource acquisition, fixture transport and per-execution preparation in all three engines on Linux and macOS | | Reporter | `npm run test:reporter -- firefox` | Inspection, output, timings, reruns, and portable saved reports in all three engines on Linux and macOS | | Native | `npm run test:native -- firefox` | Native defaults, capabilities, state restoration, dialogs, diagnostics, and cancellation in all three engines on Linux and macOS | | Capture | `npm run test:capture` | Actual pixels and capture lifecycle in Chromium, Firefox, and WebKit on Linux and macOS, using Node 24 | @@ -21,11 +22,11 @@ node bin/dyno.js install --browser firefox npm run test:browser -- firefox ``` -Tests live under `tests/core`, `tests/package`, `tests/browser`, `tests/execution`, `tests/reporter`, `tests/capture`, and `tests/native`. The runner discovers every `.test.js` file in the selected directory and rejects test files outside these suites. Keep browser-free checks in core and browser workflows in the relevant feature suite. Tests use real CLI, server, and Playwright browser boundaries; these groups only decide where they run. +Tests live under `tests/core`, `tests/package`, `tests/browser`, `tests/execution`, `tests/project`, `tests/reporter`, `tests/capture`, and `tests/native`. The runner discovers every `.test.js` file in the selected directory and rejects test files outside these suites. Keep browser-free checks in core and browser workflows in the relevant feature suite. Tests use real CLI, server, and Playwright browser boundaries; these groups only decide where they run. -Core checks and actionlint workflow validation run first. Browser and package suites run in parallel once core passes. Documentation runs separately. Browser, execution, reporter, native, and capture suites have separate jobs so each complete suite has its own budget. Each test job has a hard five-minute cap, including setup. Browser and execution checks run up to three independent files together; each owns its app, gateway, browser profiles, and fixture directory. Tests inside a file stay sequential. Reporter, native, capture, and package suites stay sequential. The macOS process-group check runs once in core instead of repeating for each browser. There are no retries or extra sleeps. +Core checks and actionlint workflow validation run first. Browser and package suites run in parallel once core passes. Documentation runs separately. Browser, execution, project, reporter, native, and capture suites have separate jobs so each complete suite has its own budget. Each test job has a hard five-minute cap, including setup. Browser and execution checks run up to three independent files together; each owns its app, gateway, browser profiles, and fixture directory. Tests inside a file stay sequential. Project, reporter, native, capture, and package suites stay sequential. The macOS process-group check runs once in core instead of repeating for each browser. There are no retries or extra sleeps. -Execution and reporter checks have their own suites because the combined macOS Firefox browser suite reached 288–294 seconds, leaving no room for setup under the five-minute cap. Every test and engine/OS combination remains covered. Product suites and benchmarks keep their configured execution; this grouping only changes Dyno's own CI. +Execution and reporter checks have their own suites because the combined macOS Firefox browser suite reached 288–294 seconds. Project setup and preparation now have their own suite after execution exhausted the same cap. Every test and engine/OS combination remains covered. Product suites and benchmarks keep their configured execution; this grouping only changes Dyno's own CI. ## Results and setup diff --git a/docs/guide.md b/docs/guide.md index 409fc19..2f12f23 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 | @@ -606,7 +608,11 @@ Manual runs use the actual window. A browser can't reliably resize its own tab, ## Native browser actions -Use native input when you're testing the browser's default behavior, such as Tab navigation or CSS hover. Keep ordinary clicks, fields, and assertions in Interactor. +Interactor's `click`, `type`, `press`, and assertions run directly in the browser. Use `press` for app-defined keyboard handlers, such as chart arrows or search shortcuts. These don't need a driver. + +Use a custom Interactor action with the element's `scrollBy` or `scrollTo` for scrolling. App scroll handlers and lazy loading still run in the real browser. + +Use native input when you're testing browser defaults that synthetic events don't reproduce, such as Tab navigation or CSS hover. ```js await I.find('Name').then.focus(); @@ -618,6 +624,8 @@ 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. +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/package-lock.json b/package-lock.json index d4b1927..223c970 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@jridgewell/trace-mapping": "0.3.31", "commander": "15.0.0", "esbuild": "0.28.2", - "interactor.js": "3.6.2", + "interactor.js": "git+https://github.com/wwilsman/interactor.js.git#d22e523485359a924cedfbc32ee3912c80319dbb", "parse5": "8.0.0", "playwright-core": "1.64.1", "pngjs": "7.0.0" @@ -543,8 +543,8 @@ }, "node_modules/interactor.js": { "version": "3.6.2", - "resolved": "https://registry.npmjs.org/interactor.js/-/interactor.js-3.6.2.tgz", - "integrity": "sha512-pn+0I3zqt0XLjCHVppHlCbKnzp+c9d1NUfOWbKW7L8a9QzgKL2bp94gHPlz72XrHVywWgvWLmRnUd2LcAknZHQ==", + "resolved": "git+https://github.com/wwilsman/interactor.js.git#d22e523485359a924cedfbc32ee3912c80319dbb", + "integrity": "sha512-WI53GM7lK1nandPLTEkiq6Ic+XlHYw4G1YpafhirJ6q3wSsx2+l3qergqTZeaLH73WLE41iEb7jIDBHq8q1Sbw==", "license": "MIT" }, "node_modules/parse5": { diff --git a/package.json b/package.json index 9a6534d..1266932 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:core": "node scripts/test.js core", "test:browser": "node scripts/test.js browser", "test:execution": "node scripts/test.js execution", + "test:project": "node scripts/test.js project", "test:reporter": "node scripts/test.js reporter", "test:package": "node scripts/test.js package", "test:capture": "node scripts/test.js capture", @@ -54,7 +55,7 @@ "@jridgewell/trace-mapping": "0.3.31", "commander": "15.0.0", "esbuild": "0.28.2", - "interactor.js": "3.6.2", + "interactor.js": "git+https://github.com/wwilsman/interactor.js.git#d22e523485359a924cedfbc32ee3912c80319dbb", "parse5": "8.0.0", "playwright-core": "1.64.1", "pngjs": "7.0.0" diff --git a/scripts/test.js b/scripts/test.js index 1380d82..20feab0 100644 --- a/scripts/test.js +++ b/scripts/test.js @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url'; import { browserEngine } from '../src/browsers.js'; let [suite = 'all', browser = 'chromium', ...extra] = process.argv.slice(2); -let directories = ['core', 'browser', 'execution', 'reporter', 'package', 'capture', 'native']; +let directories = ['core', 'browser', 'execution', 'project', 'reporter', 'package', 'capture', 'native']; let suites = ['all', ...directories]; let needsBrowser = suite === 'all' || (directories.includes(suite) && suite !== 'core'); if (!suites.includes(suite) || extra.length || (!needsBrowser && process.argv[3])) - throw new Error('Usage: node scripts/test.js [all|core|browser|execution|reporter|package|capture|native] [chromium|firefox|webkit for browser suites]'); + throw new Error('Usage: node scripts/test.js [all|core|browser|execution|project|reporter|package|capture|native] [chromium|firefox|webkit for browser suites]'); let root = fileURLToPath(new URL('../', import.meta.url)); let files = []; diff --git a/src/browser-control.js b/src/browser-control.js index 6bc65e4..dcb94f8 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 = () => {} }) { @@ -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..3e62274 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), + }), 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..adb3f05 100644 --- a/src/browser/interactor.js +++ b/src/browser/interactor.js @@ -77,7 +77,7 @@ */ /** - * Send a real key to the focused app element. Requires managed keyboard support; ordinary Interactor `press` uses synthetic events. + * Send a real key to the focused app element when browser defaults matter, such as Tab navigation. Requires managed keyboard support. Use upstream `press` for app-defined keyboard handlers; it runs directly in the browser. * * @api Interactor.nativePress */ @@ -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: * @@ -256,8 +256,8 @@ function isDisplayed(element, minimumOpacity = 0) { return true; } -// Interactor.js 3.6.2's defaults use the runner's Event/MouseEvent/document. -// Its public extensions keep those three operations in the target element's realm. +// Explicit click triggers need MouseEvent coordinates, including canvas actions. +// Check cancellation at the DOM boundary before dispatching an event. Interactor.defineAction('trigger', function* trigger(eventName, properties) { return yield ({ $, signal }) => dispatch($, eventName, properties, signal); }); @@ -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) { @@ -362,15 +363,6 @@ Interactor.defineAssertion( '#{this} is rendered', ); -Interactor.defineAssertion( - 'focused', - () => - ({ $ }) => - $ === $.ownerDocument.activeElement, - '#{this} is not focused', - '#{this} is focused', -); - // DOM focus works in a background app tab too. Browser window activation is // outside local interaction; focus() and the focused assertion check the element. Interactor.defineAssertion( @@ -418,6 +410,7 @@ 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 }, }); } diff --git a/src/browser/native.js b/src/browser/native.js index f46cf5b..8bfc69e 100644 --- a/src/browser/native.js +++ b/src/browser/native.js @@ -1,6 +1,10 @@ /** * @guide native Native browser actions - * Use native input when you're testing the browser's default behavior, such as Tab navigation or CSS hover. Keep ordinary clicks, fields, and assertions in Interactor. + * Interactor's `click`, `type`, `press`, and assertions run directly in the browser. Use `press` for app-defined keyboard handlers, such as chart arrows or search shortcuts. These don't need a driver. + * + * Use a custom Interactor action with the element's `scrollBy` or `scrollTo` for scrolling. App scroll handlers and lazy loading still run in the real browser. + * + * Use native input when you're testing browser defaults that synthetic events don't reproduce, such as Tab navigation or CSS hover. * * ```js * await I.find('Name').then.focus(); @@ -12,6 +16,8 @@ * * 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. * + * 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 +183,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 +199,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 +231,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; } @@ -271,6 +277,9 @@ export function createNative({ x: point?.x, y: point?.y, }), + 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 +289,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 +341,21 @@ export function createNative({ }), }); } + +/** + * 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 + */ 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..7485c16 100644 --- a/src/native.js +++ b/src/native.js @@ -7,6 +7,7 @@ export function nativeCapabilities(name = null) { return Object.freeze({ keyboard: managed, pointer: managed, + network: managed, permissions: chromium ? clipboard : name === 'webkit' @@ -28,6 +29,9 @@ let properties = Object.freeze({ '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'], @@ -40,6 +44,9 @@ let requiredCapability = Object.freeze({ 'remove-authenticator': 'authenticator', offline: 'offline', motion: 'media', + 'color-scheme': 'media', + 'block-external': 'network', + 'blocked-requests': 'network', dialog: 'dialogs', 'dialog-result': 'dialogs', 'dialog-cancel': 'dialogs', @@ -121,6 +128,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..03eb86f 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) { @@ -193,6 +200,35 @@ export function createNative({ context, page, capabilities, close }) { case 'pointer': await page.mouse.move(input.x, input.y); 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 }); diff --git a/src/providers/playwright.js b/src/providers/playwright.js index 2fb79a1..dd696c0 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) => { diff --git a/tests/browser/framework.test.js b/tests/browser/framework.test.js index c678ff3..a48d005 100644 --- a/tests/browser/framework.test.js +++ b/tests/browser/framework.test.js @@ -45,10 +45,10 @@ test('observes property-only changes and completes actions that replace their ta test('follows full-document links and explicit redirects without stale reads', async ({app, I}) => { await app.goto('/'); await I.click('Other page'); - await I.find('Other page').visible(); + await I.find('"Other page" $(h1)').visible(); await app.goto('/redirect'); await I.assert(() => app.window.location.pathname === '/other', 'Expected the other page URL'); - await I.find('Other page').visible(); + await I.find('"Other page" $(h1)').visible(); }); });`, }); @@ -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..17923de 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,84 @@ 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)]; + assert.deepEqual(pixel(images[0], 10, 750), [20, 160, 80, 255], 'The last fully covered viewport row must stay painted'); + let edge = pixel(images[0], 10, 751); + // WebKit can paint partial alpha or leave this fractional row transparent. + if (edge[3] > 0) + assert.ok(edge[1] > edge[2] && edge[2] > edge[0], 'A painted fractional 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..91e1968 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,62 @@ 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 validates browser-only operations and rejects overlapping DOM actions', () => { + let chromium = nativeCapabilities('chromium'); + assert.equal(chromium.network, true); + for (let name of ['chromium', 'firefox', 'webkit']) { + let capabilities = nativeCapabilities(name); + assert.equal(capabilities.network, true); + } + + for (let input of [ + { kind: 'key', key: 'Tab' }, + { kind: 'pointer', x: 8.5, y: 12 }, + { 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: 'Ordinary text' }, chromium], + [{ kind: 'click', x: 1, y: 1 }, chromium], + [{ kind: 'tap', x: 1, y: 1 }, chromium], + [{ kind: 'wheel', deltaX: 0, 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(`