From f490391f97dc86ff5318e5ff2eb58fa240e8a6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 14:29:24 +0200 Subject: [PATCH] feat(playwright): support Electron apps `argosScreenshot` and `argosAriaSnapshot` threw on a window launched with `_electron.launch()`: Electron contexts have no viewport and no browser. - Without a viewport, read the window's inner size from the page. - Without a browser, record `electron` with the version from the user agent, or no browser when the app replaced its user agent. - Reject the `viewports` option on pages without a viewport, before touching the page: setting a viewport on an Electron window can't be undone (it drops to a device scale factor of 1 and stops following its window). The `viewports` checks now run with the other argument checks, so a rejected call no longer leaves the page stabilized or uses up a capture index. Refs ARG-514 Co-Authored-By: Claude Opus 5.5 --- packages/playwright/e2e.spec.ts | 50 +++++++++++++++++ packages/playwright/src/screenshot.ts | 32 ++++++----- packages/playwright/src/util.test.ts | 62 ++++++++++++++++++++- packages/playwright/src/util.ts | 78 +++++++++++++++++---------- 4 files changed, 180 insertions(+), 42 deletions(-) diff --git a/packages/playwright/e2e.spec.ts b/packages/playwright/e2e.spec.ts index 5225c215..f9367bc4 100644 --- a/packages/playwright/e2e.spec.ts +++ b/packages/playwright/e2e.spec.ts @@ -7,6 +7,7 @@ import { argosScreenshot as typedArgosScreenshot } from "./src"; // @ts-ignore import { argosScreenshot as argosScreenshotCjs } from "./dist/index.cjs"; import { argosAriaSnapshot } from "./dist/index.mjs"; +import { readMetadata } from "@argos-ci/util"; test.describe.configure({ mode: "serial" }); const screenshotFolder = "screenshots"; @@ -182,6 +183,55 @@ test.describe("#argosScreenshot", () => { }); }); + test.describe("without a viewport", () => { + // Like an Electron window: the page is sized by its window. The device + // preset of the project sets a `deviceScaleFactor`, which requires a + // viewport, so the context is created by hand. + let context: BrowserContext; + let page: Page; + + test.beforeEach(async ({ browser }) => { + context = await browser.newContext({ + viewport: null, + deviceScaleFactor: undefined, + }); + page = await context.newPage(); + await page.goto(fixture("basic.html")); + }); + + test.afterEach(async () => { + await context.close(); + }); + + test("reads the viewport from the window", async () => { + const attachments = await argosScreenshot(page, "without-viewport", { + fullPage: false, + }); + await expectSnapshotToExists("without-viewport"); + + const screenshot = attachments.find( + (attachment) => attachment.contentType === "image/png", + ); + const metadata = await readMetadata(screenshot!.path); + expect(metadata?.viewport).toEqual( + await page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })), + ); + }); + + test("does not support the viewports option", async () => { + await expect( + argosScreenshot(page, "without-viewport-viewports", { + viewports: ["iphone-4"], + }), + ).rejects.toThrow( + "viewports option is not supported on a page without a viewport, such as an Electron window", + ); + }); + }); + test.describe("with `waitForBackgroundImages`", () => { // A 1x1 transparent PNG, served by the route below. const PNG = Buffer.from( diff --git a/packages/playwright/src/screenshot.ts b/packages/playwright/src/screenshot.ts index b439176f..107d893f 100644 --- a/packages/playwright/src/screenshot.ts +++ b/packages/playwright/src/screenshot.ts @@ -29,10 +29,10 @@ import { checkIsFrame, checkIsPage, checkIsUsingArgosReporter, + getPage, getPathAndMetadata, getSnapshotNames, getTestInfo, - getViewportSize, PNG_EXTENSION, prepare, screenshotToSnapshotPath, @@ -59,6 +59,7 @@ export type ArgosScreenshotOptions = { /** * Viewports to take screenshots of. + * Not supported on pages without a viewport, such as Electron windows. */ viewports?: ViewportOption[]; @@ -191,6 +192,19 @@ export async function argosScreenshot( if (!name) { throw new Error("The `name` argument is required."); } + if (viewports) { + if (checkIsFrame(handler)) { + throw new Error(`viewports option is not supported with an iframe`); + } + // A page without a viewport (Electron window, `viewport: null`) is sized + // by its window. Setting one can't be undone: the page would stop + // following its window and lose its device scale factor. + if (!handler.viewportSize()) { + throw new Error( + `viewports option is not supported on a page without a viewport, such as an Electron window`, + ); + } + } const screenshotTarget = typeof element === "string" @@ -208,10 +222,6 @@ export async function argosScreenshot( await prepare({ handler, useArgosReporter, root }); - const originalViewportSize = checkIsFrame(handler) - ? null - : getViewportSize(handler); - const fullPage = options.fullPage !== undefined ? options.fullPage @@ -349,13 +359,12 @@ export async function argosScreenshot( // If no viewports are specified, take a single screenshot if (viewports) { - if (checkIsFrame(handler)) { - throw new Error(`viewports option is not supported with an iframe`); - } + const page = getPage(handler); + const originalViewportSize = page.viewportSize(); // Take screenshots for each viewport for (const viewport of viewports) { const viewportSize = resolveViewport(viewport); - await setViewportSize(handler, viewportSize); + await setViewportSize(page, viewportSize); // The viewport suffix is part of the name, so the baselines need it too. const attachments = await stabilizeAndScreenshot( getScreenshotName(name, { viewportWidth: viewportSize.width }), @@ -367,10 +376,9 @@ export async function argosScreenshot( } // Restore the original viewport size - if (!originalViewportSize) { - throw new Error(`Invariant: viewport size must be saved`); + if (originalViewportSize) { + await setViewportSize(page, originalViewportSize); } - await setViewportSize(handler, originalViewportSize); } else { const attachments = await stabilizeAndScreenshot(name, baseNames); allAttachments.push(...attachments); diff --git a/packages/playwright/src/util.test.ts b/packages/playwright/src/util.test.ts index 3c401070..16cdbf34 100644 --- a/packages/playwright/src/util.test.ts +++ b/packages/playwright/src/util.test.ts @@ -2,10 +2,11 @@ import { describe, it, expect } from "vitest"; import { checkIsUsingArgosReporter, getAutomaticScreenshotName, + getBrowserMetadata, getSnapshotNames, } from "./util"; import type { TestCase, TestResult } from "@playwright/test/reporter"; -import type { TestInfo } from "@playwright/test"; +import type { Browser, Page, TestInfo } from "@playwright/test"; describe("getAutomaticScreenshotName", () => { const createMockTest = ( @@ -261,3 +262,62 @@ describe("checkIsUsingArgosReporter", () => { expect(checkIsUsingArgosReporter(testInfo)).toBe(false); }); }); + +const ELECTRON_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) my-app/1.0.0 Chrome/140.0.7339.41 Electron/38.2.0 Safari/537.36"; + +const CHROME_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7339.41 Safari/537.36"; + +describe("getBrowserMetadata", () => { + /** + * Create a page whose `evaluate` resolves to `userAgent`, as the real + * function only runs in a browser. + */ + const createMockPage = (args: { + browser?: Browser; + userAgent?: string; + }): Page => + ({ + context: () => ({ browser: () => args.browser ?? null }), + evaluate: async () => args.userAgent, + }) as unknown as Page; + + it("returns the browser of the context", async () => { + const browser = { + browserType: () => ({ name: () => "chromium" }), + version: () => "140.0.7339.41", + } as unknown as Browser; + const page = createMockPage({ browser }); + expect(await getBrowserMetadata(page)).toEqual({ + name: "chromium", + version: "140.0.7339.41", + }); + }); + + it("detects Electron when the context has no browser", async () => { + const page = createMockPage({ userAgent: ELECTRON_USER_AGENT }); + expect(await getBrowserMetadata(page)).toEqual({ + name: "electron", + version: "38.2.0", + }); + }); + + it("reads Electron pre-release versions", async () => { + const page = createMockPage({ + userAgent: ELECTRON_USER_AGENT.replace( + "Electron/38.2.0", + "Electron/39.0.0-beta.3", + ), + }); + expect(await getBrowserMetadata(page)).toEqual({ + name: "electron", + version: "39.0.0-beta.3", + }); + }); + + it("returns undefined when the browser can't be identified", async () => { + const page = createMockPage({ userAgent: CHROME_USER_AGENT }); + expect(await getBrowserMetadata(page)).toBeUndefined(); + }); +}); diff --git a/packages/playwright/src/util.ts b/packages/playwright/src/util.ts index c40d9afc..0debb1e3 100644 --- a/packages/playwright/src/util.ts +++ b/packages/playwright/src/util.ts @@ -170,12 +170,36 @@ export function getPage(handler: Page | Frame): Page { /** * Get the viewport size. */ -export function getViewportSize(page: Page) { +export async function getViewportSize(page: Page): Promise { const viewportSize = page.viewportSize(); - if (!viewportSize) { - throw new Error("Snapshots can't be taken without a viewport."); + if (viewportSize) { + return viewportSize; } - return viewportSize; + // No emulated viewport (Electron windows, `viewport: null`): the page is + // sized by its window. + return page.evaluate(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); +} + +/** + * Get the browser running the page, or `undefined` if it can't be identified. + */ +export async function getBrowserMetadata( + page: Page, +): Promise { + const browser = page.context().browser(); + if (browser) { + return { name: browser.browserType().name(), version: browser.version() }; + } + // Electron contexts have no browser, but Electron adds its version to the + // default user agent. An app that overrides it stays unidentified. + const userAgent = await page.evaluate(() => navigator.userAgent); + const electronVersion = userAgent.match(/\bElectron\/(\S+)/)?.[1]; + return electronVersion + ? { name: "electron", version: electronVersion } + : undefined; } type SnapshotNames = { @@ -300,28 +324,27 @@ export async function getPathAndMetadata(args: { const dir = dirname(path); - const [colorScheme, mediaType, libMetadata, testMetadata] = await Promise.all( - [ - handler.evaluate(() => - ((window as any).__ARGOS__ as ArgosGlobal).getColorScheme(), - ), - handler.evaluate(() => - ((window as any).__ARGOS__ as ArgosGlobal).getMediaType(), - ), - getLibraryMetadata(), - getTestMetadata(testInfo), - dir !== root ? mkdir(dir, { recursive: true }) : null, - ], - ); - - const viewportSize = checkIsFrame(handler) ? null : getViewportSize(handler); + const [ + colorScheme, + mediaType, + libMetadata, + testMetadata, + viewportSize, + browser, + ] = await Promise.all([ + handler.evaluate(() => + ((window as any).__ARGOS__ as ArgosGlobal).getColorScheme(), + ), + handler.evaluate(() => + ((window as any).__ARGOS__ as ArgosGlobal).getMediaType(), + ), + getLibraryMetadata(), + getTestMetadata(testInfo), + checkIsFrame(handler) ? null : getViewportSize(handler), + getBrowserMetadata(getPage(handler)), + dir !== root ? mkdir(dir, { recursive: true }) : null, + ]); - const browser = getPage(handler).context().browser(); - if (!browser) { - throw new Error("Can't take screenshots without a browser."); - } - const browserName = browser.browserType().name(); - const browserVersion = browser.version(); const url = overrides?.url ?? handler.url(); const metadata: ScreenshotMetadata = { @@ -330,10 +353,7 @@ export async function getPathAndMetadata(args: { mediaType, test: testMetadata, story: overrides?.story, - browser: { - name: browserName, - version: browserVersion, - }, + browser, ...libMetadata, };