diff --git a/.jscpd-baseline.json b/.jscpd-baseline.json index 75910ca3..a5e74326 100644 --- a/.jscpd-baseline.json +++ b/.jscpd-baseline.json @@ -7,7 +7,6 @@ "8a060e80d0b739f6": 1, "938e89086d5dc1f1": 1, "9bf006a9ac0610a2": 1, - "a2ffe737302d8522": 1, "abecc2a6b6900f86": 1, "ade32fb042158fe0": 1, "b44f3991fa8ccb97": 1, diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2bb406..37966422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Edit overrides in VS Code.** **Open in VS Code**, on an override's tab, its right-click menu in the Explorer or the command palette, opens its file there. Save it in VS Code, or any other editor, and the page reloads with your version, just as if you had saved in the app. A tab left open shows the new text; if it has edits you haven't saved, it keeps them and offers the saved file's version instead. **Show in folder** finds the file for any other editor. - **Share your overrides.** The new menu on the Explorer's **Overrides** section (or the command palette) exports a workspace's overrides and rules as one file, and imports one: send it to a teammate and they see the page with your fix, or copy your changes to another workspace. An import adds only what the workspace doesn't have yet, so your own versions are kept, and tells you what it left out. +- **Open the page in your other browsers.** The icons beside the address bar open a menu of the browsers on your computer (Chrome, Firefox, Safari, Edge, Brave…), each with its version: pick one and the page opens there, to see whether something looks broken in it. The command palette has them too (**Open in Firefox**), and **Settings › Browsers** hides the ones you don't want offered, or adds one the app didn't find. +- **Open it with your changes.** Chromium browsers and Firefox also offer **Open in … with your changes**: the app starts the browser with a profile of its own and serves your overrides and rules in every tab you open there, reloading them after a change as it does its own page. The browser menu lists those tabs: bring one to the front, open it in the app or capture it. The browser stays open when you let go of it, and the app finds it again next time. In Firefox, an override replaces the whole response (its headers are its file type's) and patch mode serves your saved text; an override for one GraphQL operation answers there too. +- **WebKit with your changes.** Safari can only be opened as it is, so the browser menu offers **WebKit**, Playwright's build of Safari's engine, on every system: the first time, it asks to download it (about 90 MB, its progress in the menu), then opens the page there with your overrides and rules. **Settings › Browsers** removes the download. On Linux it may need system libraries (`sudo npx playwright install-deps webkit`). +- **Your own Chrome with your changes.** Turn on remote debugging in Chrome (`chrome://inspect/#remote-debugging`, Chrome 144 and later) and the browser menu offers **Use your own Chrome with your changes**: the page opens in a new tab of your everyday Chrome, with your extensions and logins, served your changes. Your other tabs are left alone, and letting go leaves Chrome as it was. +- **Captures.** The new button at the end of the preview's toolbar captures what the page shows, the whole page (up to 32,767 pixels tall, however long it is) or one element you pick (even in a cross-site frame), and lists your captures with their thumbnails, newest first, with a search. A capture's page shows it at any zoom, down to its pixels and the colour under the pointer; copy it, save a copy or rename it. Each workspace keeps its own. +- **Check the page against a design.** Import a design (PNG, JPEG or WebP; drop or paste it on the menu too; a name with `@2x` sets its scale), or bring a frame in from **Figma** by its link with your personal access token, and compare it with the page captured at the design's width: side by side, swiped, faded over it, or as their difference, with how much of the page differs and where. Edges only smoothed differently (anti-aliasing) show in yellow and don't count. +- **A design over the live page.** **Put over the page** lays a design over the page itself, at its width, with its opacity, a difference blend and a nudge to line it up, even on a site whose Content-Security-Policy is strict. It stays through reloads and out of your captures, and goes over the tabs of the browsers you opened with your changes too. +- **Capture in every browser.** **Capture › In every browser** captures the whole page in the app and in every browser you opened with your changes, at the same size, and shows them side by side against the app's capture or a design, each with how much of it differs. +- **Your open tabs.** The browser menu can list the tabs open in your everyday Firefox, and on macOS in your Safari, Chrome, Edge, Brave, Arc or Vivaldi while they run (macOS asks you first): pick one to open its page in the app. + ### Changed - **No more white title bar on Linux.** The editor's window no longer has the system's light title bar above its own. The app's dark title bar is now the top of the window: drag it to move the window, and the minimize, maximize and close buttons sit at its right end, in its colours. diff --git a/README.md b/README.md index 757d31ed..57100056 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ Each workspace keeps its own page, tabs, overrides, rules and actions, so a fix Right-click a file to block it (an analytics script, a slow third-party iframe) before it reaches the server, or to remove a page's Content-Security-Policy, with an Undo. Rules can also set or remove any response header, or let the page call an API on another origin, preflights and cookies included. Each rule shows how often it applied and to which URLs. +### Check it in other browsers and against designs + +- **Your other browsers** (Chrome, Firefox, Safari, Edge…) are a click away beside the address bar. Chromium browsers and Firefox can also open the page **with your changes**: the app serves your overrides and rules in their tabs too, lays your design over them, and lists those tabs. So can **WebKit** (Playwright's build of Safari's engine, downloaded the first time you use it) on any system, and **your own Chrome** once you turn on remote debugging for it (`chrome://inspect/#remote-debugging`): only the tabs the app opens there get your changes. +- **Capture** what the page shows, the whole page or one element, here or in those browsers; **Capture in every browser** takes them all at once, at the same size, and shows where they differ. +- **Compare with a design** pixel by pixel: side by side, swiped, faded, or as their difference with how much differs and where, or lay the design over the live page at its width. Bring a design in from a file, by dropping or pasting it, or from **Figma** by a frame's link (with a personal access token, kept encrypted). + ### And - **Never lose work.** Closing the app keeps unsaved edits as drafts and reopens your tabs and the last page; overrides switch on and off one by one. @@ -205,8 +211,15 @@ Everything stays on your machine: no telemetry, no uploads. Besides the sites yo | Settings | `settings.json` | | The last version run, to know when to show What's New | `update.json` | | Workspaces: each one's name and tile, last page, open tabs, unsaved drafts and site icon | `session/` | +| Captures and designs, with their thumbnails | `workspace/shots.json`, `workspace/shots/` | +| Browsers you added or hid | `browsers.json` | +| Your Figma token, encrypted by the system (once it has worked) | `figma-token` | +| The WebKit build, once downloaded | `browsers/playwright/` | +| The profiles of browsers opened with your changes (their logins stay there) | `browsers/` | | The site's cookies, logins, storage | A persistent browser profile used only by the site view | +Your everyday Firefox's session file, and on macOS your other browsers' tabs (through scripting, which macOS asks you to allow), are read only when you ask the browser menu for your open tabs, and only their addresses and titles are shown. + The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application Support/Console Editor` on macOS and `%APPDATA%\Console Editor` on Windows. Uninstalling the app keeps it. Running from source uses a separate `Console Editor (dev)` folder next to it, so a dev build never touches your real data. Set `CONSOLE_EDITOR_USER_DATA` to use another folder. ## Limitations @@ -221,6 +234,8 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S - A response override with **Send request** on (the default for a GET) answers after the server has: the request is still sent, so a POST would still create what it creates, which is why overrides made from anything but a GET start with it off. An event stream an override matches is replaced as a whole, which ends it. - A paused request waits only as long as the page does: if the page gives up on it (a timeout, leaving the page), its tab closes with a note. - WebSocket messages are shown, not changed: Chromium reports them but can't hold or edit them. A HAR import makes overrides for fetch and XHR responses only, not for documents or scripts. +- In Firefox opened with your changes, an override answers before the request is sent (Firefox can only replace a response then): its headers are its file type's rather than the server's, patch mode serves your saved text, and a script behind an `integrity` attribute needs the page's HTML overridden too. Breakpoints, the console and the Network panel stay with the app's own page. Safari is opened as it is: check its engine with your changes in WebKit, which quits when you let go of it. On Linux, WebKit needs system libraries you may have to install (`sudo npx playwright install-deps webkit`). +- A full-page capture stops at 32 767 device pixels tall, the tallest image the compare page can draw. Past 16 384 (Chromium's limit for one capture) it is captured in parts and joined, which takes a while on a long page at high density. - Chromium's local-network checks are off in the app's browser, so a patched localhost or intranet page can still reach its own servers. Browse only sites you're working on (see [SPEC §8](docs/SPEC.md#8-security)). ## Roadmap @@ -228,9 +243,8 @@ The data folder is `~/.config/Console Editor` on Linux, `~/Library/Application S What shipped, release by release, is in the **[CHANGELOG](CHANGELOG.md)**. Next: - [ ] Parameters and scenarios for actions -- [ ] Edit in your own editor (watch the overrides folder), and export/import patch sets for teammates - [ ] Search across every file the page loaded -- [ ] Drive your own Chrome over CDP +- [ ] The console, Network panel and inspector for a tab in another browser ([research](docs/BROWSERS_RESEARCH.md)) - [ ] Vue's renders and a data-flow view in the component inspector ([research](docs/INSPECTOR_RESEARCH.md)) - [ ] Signed and notarized builds (and with them, installing updates in place on macOS) diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 59ee0aff..4c977e77 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -17,5 +17,8 @@ com.apple.security.personal-information.location + + com.apple.security.automation.apple-events + diff --git a/docs/BROWSERS_RESEARCH.md b/docs/BROWSERS_RESEARCH.md new file mode 100644 index 00000000..b192c834 --- /dev/null +++ b/docs/BROWSERS_RESEARCH.md @@ -0,0 +1,323 @@ +# Research: other browsers, captures and designs + +**Status.** Phases 1 to 8 are built (SPEC §6.16–§6.18), and phase 9 but for the console, Network panel and inspector of an outside tab. WebKit's build couldn't be downloaded here, so it is checked with Playwright's Chromium standing in for it (see [Phase 8](#phase-8-webkit)). The to-do list below says, item by item, what was built and where it went another way. + +**Question.** Two things in one request: + +1. **Other browsers.** Show the browsers installed on this computer next to the address bar. Open the page in any of them to see whether something is broken there, and search the tabs they have open. +2. **Captures and designs.** Put a thumbnail at the right end of the toolbar that opens a list of images. The images are captures of the page, and designs to check it against pixel by pixel. + +**Short answer.** Yes, in steps. Four findings shape the design: + +1. **Opening a page in another browser works everywhere. Opening it with your overrides and rules depends on the browser's engine:** + - **Chromium browsers** (Chrome, Edge, Brave, Vivaldi, Opera, Arc, Chromium): the app's engine already runs over a WebSocket connection (`engine/websocketTransport`, which the integration tests use). A Chromium browser the app launches can serve the workspace's changes the way the app's own page does. This is roadmap M3's external Chrome. + - **Firefox** no longer speaks CDP. WebDriver BiDi can pause and answer requests, but it needs a second, smaller engine that reuses the app's matching, rules and transforms. + - **Safari** has no automation that can change what it loads. The app can only open the page in it. To check the WebKit engine with your changes, the app can use Playwright's WebKit build, on every system. +2. **Captures come from CDP.** The viewport, the full page or one element can be captured at any pixel density, without resizing the window. +3. **A design can go over the live page, even on sites whose CSP forbids images from other sources.** The app draws the design on a canvas in an isolated world (verified below). It can't be HTML in the app's own window, because the native page view is drawn above the app's DOM. +4. **Most of the plumbing exists.** The app already has: a CDP connection to an outside browser, a transport-agnostic engine, the element picker, per-workspace stores and a still of the page for popovers. + +--- + +## 1. What the reference app does + +The screenshots show two popovers. + +- **Browser switcher.** At the address bar's left is a 2×2 cluster of browser icons (Arc, Safari, Chrome, and a dashed slot to add one). It opens a list headed **Search browser tabs…**, with a gear and a chevron. Tabs are grouped by browser (Arc: a Figma file, a `localhost:5199` page, …), each with its favicon, title and host. +- **Files.** At the toolbar's right is a stack of thumbnails. It opens a list headed **Search files…**, with segments **All · Captures · Exports**. Each row has a thumbnail, a file name (`page-834.png`, `page-834-full.png`), a line such as "Export · Image · 15h ago", and a chevron. + +## 2. What the app has today + +| Piece | Where | What this work gets from it | +|---|---|---| +| A still of the page | `PageController/snapshotPage` (`capturePage()`: the viewport, as JPEG), `usePageSnapshot` | Popovers over the page freeze it into this still, since the native view is drawn above the DOM. The two new popovers do the same | +| CDP over WebSocket | `engine/websocketTransport` (`CdpConnection`, `attachToPage`). `test/helpers/chromium.ts` launches Chromium with `--remote-debugging-port=0` and a profile of its own | Driving an outside Chromium browser, and running the engine on its tabs | +| A transport-agnostic engine | `InterceptionEngine` / `PageInterception` over `CdpTransport`. Plain functions live in `engine/rules` (header, CORS and block rules) and `engine/transform` (headers, charsets, SRI, source maps). Matching is in `OverrideMatcher` and `requestMatches`; patch mode is `patchLive` | Overrides and rules in a Chromium browser as they are. A Firefox engine reuses the plain functions | +| Element picking | The inspector's picker (SPEC §6.13), in any frame | Capturing one element | +| Per-workspace stores | `ActionStore`, `OverrideStore`: written atomically, one write at a time, removed with their workspace | A `ShotStore` built the same way | +| Chromium flags the app needs | `chromiumFlags` (`LOCAL_NETWORK_ACCESS_FEATURES`) | The same flags for a Chromium browser the app launches | +| Toolbar in both places | `PreviewToolbar` (placement `editor` or `window`) | The two new buttons show in the editor and in the website's own window | +| Roadmap | M2 "responsive device presets"; M3 external Chrome with a `WebSocketTransport`; story U14 | Setting a design's width uses the same emulation as device presets. Phase 5 below is M3 | + +## 3. Verified + +These were probed in Chromium 141 (Playwright's build, headless) on a page served with `Content-Security-Policy: default-src 'self'; img-src 'self'`. + +| Fact (verified) | Consequence | +|---|---| +| An `` with a `data:` URL is blocked by that CSP, even when an isolated world (`Page.createIsolatedWorld`) creates it | The overlay can't be an `` or a CSS background | +| In the same isolated world, `createImageBitmap(new Blob([bytes]))` drawn on a `` works under that CSP. With `pointer-events: none`, the page still gets the pointer (`elementFromPoint` returns the page's element under it) | The overlay is a canvas, and the page stays usable under it | +| The page's own scripts see that canvas (isolated worlds share the DOM) | Add it to ``, not ``, and put it back if the page removes it. The docs say the page can see it | +| `Page.captureScreenshot` with `captureBeyondViewport: true`, clipped to `Page.getLayoutMetrics().cssContentSize`, returns the whole page: 800×3000 from an 800×600 viewport. The viewport and scroll position are unchanged afterwards | Full-page captures need no resizing | +| `Emulation.setDeviceMetricsOverride({ width: 1440, height: 900, deviceScaleFactor: 2 })` gives `innerWidth` 1440 and `devicePixelRatio` 2, and a capture of 2880×1800 | Before comparing, the page can take a design's width and density | +| Chromium started with `--remote-debugging-port=0 --user-data-dir=` writes `/DevToolsActivePort` (the port, then `/devtools/browser/`). `/json/list` lists its tabs (id, title, URL) | The app finds a browser it launched through that file, without reading the browser's output, and lists its tabs | + +**Not probed here**, since this environment has no Firefox or WebKit: + +- Firefox's BiDi interception and reading response bodies; +- WebKit through Playwright; +- `Page.captureScreenshot` through Electron's `debugger` on a `WebContentsView`, which should behave as in Chromium since it's the same CDP; +- device emulation inside the docked view. + +Each phase that relies on one of these starts by probing it. + +## 4. Proposal + +### 4.1 The browsers on this computer + +| System | Where installed browsers are found | Icon | +|---|---|---| +| Linux | `.desktop` files whose `Categories` include `WebBrowser` or whose `MimeType` has `x-scheme-handler/https`. They are in `/usr/share/applications`, `~/.local/share/applications`, and the Flatpak and Snap exports. The launch command is the `Exec=` line without its field codes | `Icon=`, looked up in the icon theme's folders (`hicolor`, largest PNG) | +| macOS | The apps under `/Applications` and `~/Applications` whose bundle id is a known browser's (`com.google.Chrome`, `com.apple.Safari`, `company.thebrowser.Browser`, `org.mozilla.firefox`, `com.microsoft.edgemac`, `com.brave.Browser`, …) | `app.getFileIcon(appPath)` | +| Windows | `reg query` of `HKLM` and `HKCU\SOFTWARE\Clients\StartMenuInternet\*` (`shell\open\command`) | `app.getFileIcon(exePath)` | + +- **What each browser records:** an id, name, version (where it's cheap to read), launch command and icon. + - It also records an **engine**, from known ids: `chromium`, `gecko`, `webkit` or `unknown`. The engine decides what the app can do with the browser. +- **Scanning:** at most once a minute, when a menu opens. +- **Add a browser…** (the dashed slot) takes an executable. That covers portable and preview builds such as Chrome Canary or Firefox Nightly. +- **No brand logos in the repository.** Icons come from the system, which avoids trademark questions and matches what the user knows. + +### 4.2 Opening the page in one + +| Level | What happens | Browsers | +|---|---|---| +| **Open** | The current URL opens in the browser, with your everyday profile. Nothing is changed | All | +| **Open with my changes** | The app launches the browser with a profile of its own, and serves the active workspace's overrides and rules there. As with its own page, it reloads that browser's tabs on the site after a save | Chromium browsers (phase 5), Firefox (phase 7) | +| **Engine builds** | Playwright's WebKit (and, if useful, its Firefox) is downloaded when first used, and driven with your changes | WebKit on every system (phase 8) | + +**Chromium browsers (phase 5):** + +1. **Launch** ` --user-data-dir=/browsers/ --remote-debugging-port=0 --no-first-run --no-default-browser-check --disable-features= `. +2. **Connect:** wait for `DevToolsActivePort`, then `CdpConnection.connect`. `Target.setDiscoverTargets` reports tabs as they open and close. +3. **Serve:** each page tab gets `attachToPage` and a `PageInterception` running the active workspace's overrides and rules, with the app's settings. +4. **Next launch:** a browser still running (its `DevToolsActivePort` answers `/json/version`) is reused. + +Notes: + +- **Why a profile of its own:** Chrome 136 and later ignore the debugging port on the everyday profile. The profile keeps its logins between launches. +- **Snap and Flatpak** browsers can only write inside their sandbox (Snap: under `~/snap//`), so the profile folder goes there. +- **Your own Chrome (U14):** Chrome 144 and later let the user allow remote debugging of the running browser from `chrome://inspect`, and the browser asks before a client connects. That needs checking. If it holds, the same connection works on the everyday profile. +- **What drives a launched browser's tabs:** at first, only overrides, rules and settings. The console, Network and inspector stay on the app's page. A page picker for the panels can come later. + +**Firefox (phase 7):** + +- **Launch** `firefox --profile --no-remote --new-instance --remote-debugging-port=0 `. Firefox prints `WebDriver BiDi listening on ws://…`. +- **Commands used:** + - `session.new`; + - `network.addIntercept` (phases `beforeRequestSent` and `responseStarted`, with URL patterns); + - `network.provideResponse`, `continueResponse` and `failRequest`; + - `script.addPreloadScript` (the SRI guard, the overlay); + - `browsingContext.captureScreenshot` (`origin: 'document'` for the full page); + - `browsingContext.setViewport`. +- **Reading the upstream body** needs `network.addDataCollector` and `network.getData`, which are newer: probe which Firefox has them. Two features need that body: redeploy detection and patch mode. Without it they stay off in Firefox, and the menu says so. +- **A new `BidiServer`** reuses `OverrideMatcher`, `requestMatches`, `engine/rules`, `engine/transform` and `patchLive`. It doesn't share the CDP event flow. + +**Safari:** + +- **Open only.** `safaridriver` (WebDriver, macOS, after Develop › Allow Remote Automation) can capture Safari but can't change a response. +- **The menu says so:** "Safari: open only. Check WebKit with your changes →". + +**WebKit (phase 8):** + +- **The build:** Playwright's WebKit, downloaded into `/browsers/playwright` on first use, after the user agrees to the download size. It's driven with `playwright-core`, whose `route` answers requests in WebKit too. +- **The dependency:** `playwright-core` moves from the dev dependencies to the app's, pinned, since each build matches one version of it. + +### 4.3 The browser menu + +- **Where:** in the preview toolbar, left of the address bar, in both placements. The button shows up to four installed browsers' icons, as in the reference. +- **The popover:** the page freezes into its still, as for any popover over it. It holds: + - **Search browser tabs…**, and a gear that opens Settings › Browsers (which ones to show, their profile folders, Add a browser…). + - One group per browser: its icon, name and version, **Open**, and **Open with my changes** where its engine allows it. + - For a browser the app launched, its tabs (favicon, title, host). A click brings the tab forward (`Target.activateTarget`, `browsingContext.activate`). **Open here** loads the tab's address in the app. + - A dot on a browser the app is driving. +- **The palette:** "Open in Firefox", "Open in Chrome with my changes", and so on. +- **The status bar:** how many outside tabs are getting the workspace's changes. +- **Tabs of browsers the app didn't launch** (the reference lists Arc's) come later (phase 9), and depend on the system: + - macOS: by AppleScript, for Safari, Chrome, Arc, Edge and Brave. macOS asks for Automation permission first. + - Firefox: from its session file, `sessionstore-backups/recovery.jsonlz4`, on any system. + - Chrome elsewhere: only with remote debugging turned on. + - They're used to bring an address from your everyday browser into a workspace. + +### 4.4 Captures and designs (shots) + +```ts +interface Shot { + id: string; // 8 hex chars + kind: 'capture' | 'design'; + name: string; // "shop.test-cart-1440-full.png"; editable + width: number; height: number; // pixels in the file + scale: number; // device pixels per CSS pixel: 2 for a 2× capture or a 2× design export + pageUrl?: string; // a capture's page + browser?: { id: string; name: string; version: string }; // a capture's browser ('app' for the app's own page) + viewport?: { width: number; height: number }; // CSS pixels, when captured + area?: 'viewport' | 'page' | 'element'; + group?: string; // captures taken together in every browser (§4.6) + createdAt: number; updatedAt: number; +} +``` + +- **Storage:** `shots.json` (`{ version: 1, shots: (Shot & { workspaceId })[] }`), `shots/.`, and a 256 px JPEG thumbnail `shots/.thumb.jpg` made with `nativeImage.resize`. Everything is written atomically. +- **Per workspace.** A workspace's shots are deleted before the workspace itself, for the same reason its actions and overrides go first. +- **Capturing:** the viewport, the full page, or one element (the inspector's picker, then `DOM.getBoxModel` for the clip), in the app's page or in a browser it drives. Before capturing, the app can: + - wait for `document.fonts.ready` and for 500 ms without requests; + - hide the design overlay; + - optionally, pause animations (`Animation.setPlaybackRate` 0). +- **Limits:** a full page is capped at 32 767 device pixels tall, the tallest a canvas draws; past Chromium's largest texture (16 384) it is captured in parts and joined (SPEC §6.17). A file is at most 50 MB, and a workspace holds at most 500 shots. +- **Importing a design:** **Import design…** (several files), dropping files on the list, or pasting (Figma's Copy as PNG). PNG, JPEG and WebP are accepted. The renderer decodes each one to check and measure it, and the file is kept as it came. + - A design's **scale** comes from `@2x` or `@3x` in its name, else 2 when it is wider than 2000 px, else 1. It can be changed on the shot's page. + - A Figma frame by its link, through Figma's API with a personal token (SPEC §6.17). + +### 4.5 The shots list + +- **Where:** at the right end of the preview toolbar, a button shows the latest shots' thumbnails, stacked as in the reference. Its popover freezes the page, like the browser menu. +- **The popover:** + - A **Search shots…** field (name, host, browser). + - Segments **All · Captures · Designs**. The reference's "Exports" become designs. + - Rows with a thumbnail, a name, and a line: "Capture · Firefox 131 · 1440 × 900 · 15 h ago" or "Design · 1440 wide · 2 days ago". A chevron opens the shot's page. + - In the header: **Capture** (a split button: the viewport, the full page, an element, in every browser) and **Import design…**. + - A row's menu: Compare with the page, Compare with…, Put over the page, Rename, Copy image, Save as…, Show in folder, Delete. +- **The palette:** Capture the page, Capture the full page, Import a design, Open a shot… +- **A shot's page** is an editor tab of its own kind, like What's New. It shows: + - the image, at Fit, 100 %, or zoomed in to a pixel grid; + - the colour of the pixel under the pointer; + - the image's size and scale, and where it came from (address, browser, viewport). + +### 4.6 Pixel-perfect checks + +**Over the live page (the overlay):** **Put over the page**. + +- **Width:** the page takes the design's width in CSS pixels (width ÷ scale) through device emulation. In the docked view, the page is scaled down to fit, as in DevTools' device mode; that needs probing in Electron. +- **Drawing it:** the design is drawn on a canvas in an isolated world (`Page.addScriptToEvaluateOnNewDocument` with a `worldName`, and the image sent once the frame has loaded). It either scrolls with the page, absolute on the document, or stays fixed to the viewport. +- **Controls:** a bar under the preview toolbar, never inside the page. It has opacity, **Difference** blending, **Invert**, x/y nudges (arrow keys while the bar has focus, Shift for 10 px), Scroll with the page / Fixed, Hide (H) and Remove. +- **In other browsers:** the same script works in a Chromium browser the app drives, and in Firefox through a BiDi preload script. + +**On stills (the compare page):** two shots, or a shot and a fresh capture at the design's width. + +- **Modes:** **Side by side** (scrolled together), **Swipe**, **Onion skin**, and **Difference**. Difference shows differing pixels in red over a faded base, with the share of pixels that differ and boxes around the differing areas to step through. +- **Alignment:** top left, both scaled to CSS pixels, with x/y offsets. +- **The diff** runs in a worker. It's a pixelmatch-style compare: YIQ colour distance with a threshold, ignoring anti-aliased pixels. + +**Across browsers:** **Capture in every browser**. + +- **What it does:** it covers the app's page and every browser the app drives. Each gets the same address, viewport and density, and waits for the load, fonts and 500 ms without requests. Then each captures the full page. +- **The result** is saved as one group. A compare page shows the group as a grid against a baseline (the app's capture, or a design), with each capture's share of differing pixels. That's the "is something broken in Firefox?" check, in one click. + +## 5. Code layout sketch + +``` +src/main/browsers/ findBrowsers/ (linux, mac, windows), openInBrowser, DrivenBrowsers (launched ones: connection, tabs, engines), + chromium/ (launch, readDevToolsActivePort, ChromiumDriver: targets → PageInterception), + firefox/ (launch, BidiConnection, BidiServer), webkit/ (Playwright, phase 8), registerBrowserIpc +src/main/shots/ ShotStore (shots.json, files, thumbnails), captureOverCdp (viewport, page, element), importDesign, registerShotIpc +src/main/overlay/ the injected overlay source, DesignOverlay per page (CDP and BiDi adapters) +src/shared/ shots/shotSchema, types (Shot, BrowserInfo), IPC channels, events browsers-changed and shots-changed +renderer entities/ browser (installed and driven, their tabs), shot (the list) +renderer features/browser/ open-in-browser, drive-browser +renderer features/shot/ capture, import-design, compare (with the diff worker), overlay (the bar's state) +renderer widgets/ browser-menu, shots-menu, shot-page (viewer and compare), overlay-bar; page-preview gains the two buttons +``` + +- **Why the new slices are grouped** (`features/browser/`, `features/shot/`): steiger allows at most 20 ungrouped feature slices, and the app is close to that limit. +- **Structure:** every new file follows the code-structure rules. Large parts (`BidiServer`, `ShotStore`) start as folders. + +## 6. To-do list + +Each phase ends in a pull request of its own. Each updates SPEC, the CHANGELOG and, where it shows, the README. + +### Phase 1: Open in another browser +- [x] `findBrowsers` for Linux, macOS and Windows, each tested on fixtures (`.desktop` files, `reg query` output, app folders). +- [x] Icons: the Linux icon theme lookup, and `app.getFileIcon` on macOS and Windows. Each is cached as a data URL. +- [x] `openInBrowser(id, url)`: a detached spawn of the browser's own command: `open -a` on macOS, the `Exec=` line on Linux (Flatpak's through `flatpak run`), the registry's command on Windows. +- [x] IPC (`listBrowsers`, `openInBrowser`), zod-checked, and the `browsers-changed` event. +- [x] `entities/browser`, and the browser button with its popover in `PreviewToolbar`, in both placements (search, one row per browser, **Open**). +- [x] Palette items, and Settings › Browsers: which ones to show, and Add a browser…. +- [x] Tests: unit tests for the parsers; e2e with a fake browser (a script that writes the address it got to a file). + +### Phase 2: Captures +- [x] `captureOverCdp`: the viewport, the full page (with the height cap) and an element, on a `CdpTransport`. Probe it on the app's `WebContentsView` first. +- [x] `ShotStore`: files, thumbnails, `shots.json`, limits, removal with the workspace (in `WorkspaceController`'s delete order). +- [x] IPC: `capture`, `listShots`, `renameShot`, `deleteShot`, `showShotInFolder`, `saveShotAs`, `copyShot`. +- [x] `entities/shot`, and the shots button (stacked thumbnails) and its popover: search, segments, rows and row menu. +- [x] The shot page: an editor tab kind with the viewer (zoom, pixel grid, colour under the pointer, details). +- [x] An element capture from the inspector's picker, and palette items. +- [x] Tests: unit tests for the store; integration tests for full-page and element clips in Chromium; e2e: capture, list, rename, delete, restart. + +### Phase 3: Designs and the compare page +- [x] **Import design…**, dropping files, pasting: decode and measure in the renderer, guess the scale, keep the file. +- [x] The compare page: side by side, swipe, onion skin, difference, and offsets. +- [x] The diff worker: YIQ distance over a threshold, the share of pixels that differ, differing areas (16 px cells, at most 200), and anti-aliased pixels told apart (shown yellow, not counted). On a 1440×10 000 page with a tenth of its pixels differing it takes about 4 s, 2 s without telling anti-aliasing apart (`test/perf/renderer/diff.perf.test.ts`). +- [x] "Compare with the page": capture at the design's width (`Emulation.setDeviceMetricsOverride`), then compare, then clear the emulation. +- [x] Tests: unit tests for the diff (identical, shifted and anti-aliased images); e2e: import a PNG, compare it with a capture of a fixture page, check the share of differing pixels. + +### Phase 4: The overlay on the live page +- [x] The injected overlay (canvas, `pointer-events: none`, scroll or fixed, put back if removed), installed in an isolated world on every load. +- [x] `DesignOverlay` on CDP: install, update (opacity, blend, offset, invert, hidden) and remove it. Updates are small messages, and the image is sent once. +- [x] Device emulation for the design's width in the docked view and in the website's own window: Electron's `enableDeviceEmulation` (CDP's scale draws wrong in a `WebContentsView`). M2's device presets aren't built on it yet. +- [x] The overlay bar and its keys, and hiding the overlay while capturing. +- [x] Tests: e2e on a fixture page with a strict CSP: the canvas is there, the page still gets clicks, it survives a reload, and it's gone after Remove. + +### Phase 5: Chromium browsers with your changes (M3) +- [x] `launchChromium` (the flags above, a profile under `userData`, the Snap and Flatpak paths) and `readDevToolsActivePort`; reuse a browser that's still running. +- [x] `DrivenChromium`: browser-level auto-attach (a new tab waits until it is set up), a `PageInterception` per tab, following workspace switches and override, rule and settings changes. It reloads after saves, forgets a browser that closes, and reaches it again through its profile. +- [x] Tabs in the browser menu (search, bring forward, Open here), the dot, the status bar count, and **Open with my changes**. +- [x] Captures in those tabs, through the same CDP code. Not yet: the overlay in them. +- [x] Your everyday Chrome (U14): with remote debugging turned on in `chrome://inspect/#remote-debugging`, it writes `DevToolsActivePort` in its everyday profile; the app reaches it there and drives only the tabs it opens (SPEC §6.16). The permission prompt itself couldn't be probed (the container's Chromium 141 predates it); a Chromium on a stand-in everyday profile plays the part in the tests. +- [x] Tests: integration with Playwright's Chromium launched as the installed browser (an override served, a reload after a change, captures, reached again, forgotten once quit); e2e with a launcher running it. + +### Phase 6: Capture in every browser +- [x] One capture in each driven browser at the same address, viewport and density, with settle rules; saved as a group. +- [x] The group's compare grid against a baseline, with the share of differing pixels per browser. +- [x] Tests: e2e with the app's page and one launched Chromium. + +### Phase 7: Firefox with your changes +- [x] Probe BiDi in the current Firefox (157): `addIntercept` and headers at `responseStarted` hold; `provideResponse` takes a body only before the request is sent; `getData` reads a body only once the response has completed (so no patching in flight); `setCacheBehavior` stands in for reload's `ignoreCache`; the address is in the profile's `WebDriverBiDiServer.json`. Preload scripts weren't tried in cross-site iframes. +- [x] `launchFirefox` (a profile with `user.js` prefs for no first run and no updates), and `BidiConnection`. +- [x] `BidiInterception`: every request paused while an override or a rule is on (BiDi's URL patterns can't express globs and regexes), overrides answered before sending (`engine/answering`, shared), rules at both stages, the plain engine functions. Redeploy detection and patch mode are off: no body can be replaced once upstream answered. +- [x] Tabs (`browsingContext.getTree`, titles read in each page), captures (`captureScreenshot`), the viewport (`setViewport`). Not yet: the overlay. +- [x] Tests: integration and e2e tests against Firefox, skipped where it isn't found (`FIREFOX_PATH`, or `firefox` on the PATH). GitHub's Ubuntu runners ship Firefox, so CI runs them without a change. + +### Phase 8: WebKit + +Built (SPEC §6.16), and not as first planned in two ways: + +- **No installer run.** Playwright's `cli.js install webkit` forks `oopBrowserDownload.js` as Node, which the packaged app's fuses refuse (`runAsNode: false`). The app downloads the build itself instead: its URLs, folder and program from the pinned `playwright-core`'s registry (`PLAYWRIGHT_DOWNLOAD_HOST` too), unpacked with Playwright's own extractor, the program made runnable and the marker written last, as the installer does. +- **Packaging.** `playwright-core` is in the dependencies (pinned: the registry and extractor are its internals), left out of the main bundle and shipped in the asar as it is (not unpacked: nothing forks it), 134 files. An unpacked Linux build lists WebKit, downloads a stand-in build from a stand-in host and launches it. +- **Nothing to check WebKit itself with.** Playwright's CDN is refused by the development container's network. The driver speaks only Playwright's API, so Playwright's Chromium stands in for WebKit in its tests; the download is tested against a stand-in host. On a machine that can reach the CDN (CI with `npx playwright install --with-deps webkit`), a test with the real build is still to add. + +- [x] The packaging change above (checked with an unpacked build; the release workflow's packaged tests don't cover WebKit yet). +- [x] The download with progress in the menu's row, once agreed, and removing it from Settings. +- [x] A driver on `playwright-core` behind the `Driver` interface: `route` carries out `decideRequest` (fulfil, abort, continue) and, through `route.fetch`, `ruledHead` for response rules; `page.screenshot` for captures (in parts past a texture); a context per capture viewport; the design by init scripts in the page's world. +- [x] Tests: the driver with Playwright's Chromium, the download against a stand-in host, the menu's flow end to end. + +### Phase 9: Later +- [x] Tabs of everyday browsers on macOS: JavaScript for Automation rather than AppleScript's text (JSON back, each app's terms looked up when run), for Safari, Chrome, Edge, Brave, Arc and Vivaldi, only while they run (SPEC §6.16). The script is checked against a stand-in for JXA's objects; it couldn't be run on a Mac here. +- [x] Firefox's session file everywhere: `recovery.jsonlz4` of each profile in `profiles.ini`, read only when asked (SPEC §6.16). +- [x] Figma frames by link: the frame's name, its 2× render downloaded, a personal token kept with `safeStorage` once it works (SPEC §6.17). Tested against a stand-in for Figma's API (`CONSOLE_EDITOR_FIGMA_API`); `api.figma.com` can't be reached from here. +- [x] Stitching full pages taller than the texture limit: parts 4096 device pixels tall, their rows packed again as one PNG without decoding it whole (only each part's first row is unfiltered), down to 32 767 device pixels (SPEC §6.17). +- [ ] The console, Network and inspector for an outside tab. +- [x] The design over a driven browser's tab: Chromium through the same isolated-world script, Firefox through preload scripts in a sandbox, WebKit through init scripts; each at the design's width while that is on (SPEC §6.16). + +## 7. Limits and risks + +| Risk | Mitigation | +|---|---| +| Safari can't be served changes | It is only opened as it is; WebKit through Playwright's build is offered beside it, downloaded on first use | +| Firefox replaces a body only before the request is sent | Overrides answer then, with their file type's headers; redeploy detection and patch mode stay off there (README › Limitations) | +| Snap and Flatpak sandboxes | Profile folders inside the sandbox. If a launch fails, show the browser's own output | +| The page's scripts can see or remove the overlay | Add it to `` and put it back; documented | +| Sticky headers and lazy images in full-page captures | A fixed element appears once, at the top. An option scrolls through the page first, so lazy images load | +| Fonts differ between systems, so cross-system diffs are noise | Compare captures taken on one machine; the compare page names each capture's browser and system | +| Big images use a lot of memory | Caps (height, file size, shots per workspace); the diff runs in a worker on bitmaps | + +## 8. Decisions to make + +Taken: the recommendations below, as built. + +| Question | Recommendation | Alternative | +|---|---|---| +| Build order | Phases 1 → 2 → 3 → 4 first: useful on their own, and they need no outside browser. Then 5, 6, 7, 8 | Phase 5 first, since M3 and U14 are already on the roadmap | +| Where shots live | Per workspace, like overrides and actions | One library for all workspaces, filtered by host | +| The shots list | A toolbar popover (the reference), plus the palette | A rail view listing shots, with more room for groups | +| The diff | Our own small compare in a worker | `pixelmatch` (ISC licence, no dependencies) | +| WebKit | Playwright's build, downloaded on use | Only "Open in Safari" on macOS; no WebKit with changes | +| Profiles of driven browsers | One per browser under `userData`, logins kept | A new temporary profile each launch | diff --git a/docs/SPEC.md b/docs/SPEC.md index f5198071..7565104b 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -31,7 +31,7 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U12 | Overrides and file listing inside iframes, including cross-site (out-of-process) and nested ones | ✅ | | U12b | Overrides and file listing for what workers load: dedicated, shared and service workers, and worklets | ✅ (except a nested worker's first script, §6.6) | | U13 | Export/import a workspace's overrides to share them with teammates | ✅ (§5, Sharing) | -| U14 | Use my own Chrome (existing profile, extensions) instead of the embedded browser | 🔜 M3 | +| U14 | Use my own Chrome (existing profile, extensions) instead of the embedded browser | ✅ (§6.16: with remote debugging turned on for it, served my changes in the tabs the app opens) | | U15 | Browse original sources from source maps (read-only) and jump to the matching bundle code | ✅ (§6.8) | | U16 | I keep a workspace per site or task (its page, tabs, unsaved edits and overrides) and switch between them from the rail, which shows each one's favicon or a colour I pick | ✅ (§5.1) | | U17 | I read the console of the page and every iframe in it as one stream, each row tagged with its frame, and run code in the frame I pick: send an event in one service, watch another react | ✅ (§6.7) | @@ -51,6 +51,12 @@ A desktop app where you enter a website's URL, see every script, stylesheet and | U31 | I see which code sent a request: the stack of the script, traced to the originals; and on a component's page, the requests sent from its file | ✅ (§6.10, §6.13) | | U32 | I keep a state value I set on the Component page as an action, which sets it again whenever I run it (after a reload too) | ✅ (§6.13) | | U33 | I see the renders I recorded by component: how often each rendered, mounted or was skipped, how long its own renders took, and why | ✅ (§6.14) | +| U34 | I open the page in another browser installed on this computer (Chrome, Firefox, Safari, Edge…) from beside the address bar, to see whether something is broken there | ✅ (§6.16) | +| U35 | I open it in a Chromium browser or Firefox with my overrides and rules applied, and search the tabs the app opened there | ✅ (§6.16) | +| U36 | I capture the page (the viewport, the whole page or one element), in the app or in another browser, and find my captures in a list at the end of the toolbar | ✅ (§6.17) | +| U37 | I import a design and check the page against it pixel by pixel: laid over the live page, or side by side, swiped, faded or as a difference | ✅ (§6.17, §6.18) | +| U38 | I capture the page in every browser at once and see where they differ from each other or from the design | ✅ (§6.17) | +| U39 | I bring an address from my everyday Firefox (or, on macOS, Safari, Chrome, Edge, Brave, Arc or Vivaldi) into the app: its open tabs are listed in the browser menu | ✅ (§6.16) | ## 3. Architecture @@ -143,15 +149,30 @@ src/ data: handed over undecoded; 64 MB, 30 s), or picked from a file (§6.8) readCapped.ts capped streaming reads (favicons, source maps) desktopEntry/ Linux: the desktop entry and icons an AppImage or .tar.gz installs for itself (§10) + browsers/ the other browsers (§6.16): BrowserRegistry/ (found and added, their icons and versions), + findBrowsers/ (Linux desktop entries and icon themes, macOS apps, the Windows registry), + startBrowser.ts; driven/ (browsers driven with the workspace's changes: DrivenBrowsers, the + Driver interface and DrivenBase, DrivenTabs, launching with a profile and reaching one again; + chromium/ over CDP, firefox/ over WebDriver BiDi, playwright/ through Playwright (WebKit), + everyday/ your everyday Chrome); everyday/ (your everyday browsers' tabs: Firefox's session + file, macOS scripting); webkit/ (Playwright's WebKit build, downloaded on first use) + shots/ PageShots/ (captures of the app's page, designs, groups), capture/ (over CDP: an area, at a + width or a viewport, in parts past a texture), png/ (joining PNG parts), captureInEveryBrowser.ts, + image type and size, thumbnails, the `console-editor-shot:` protocol (§6.17) + figma/ Figma frames brought in as designs, and the token kept for it (§6.17) + overlay/ DesignOverlay (the design over the app's page, fitting the page to its width) and PageOverlay + (its canvas in an isolated world) (§6.18) electronTransport.ts webContents.debugger → CdpTransport engine/ PageInterception/ (one engine per CDP session: page, iframes, workers; hands each frame session to the console too), InterceptionEngine/ (the coordinator, with frame, navigation, resource, paused-request, settings and worker-script collaborators), rules/ (block, header and CORS rules: matching and header edits), transform/ (SRI/source maps/headers, the SourceMap header a response names), - cdp/ (transport interface), websocketTransport/ (browser-level CDP, used by tests), + cdp/ (transport interface), websocketTransport/ (browser-level CDP: driven Chromium browsers, + and the tests), bidi/ (WebDriver BiDi: the connection, and Firefox's interception), answering/ + (what to do with a request where a browser can only answer before sending it), constants/ (CDP command and event names, HTTP status classes) - store/ OverrideStore/, SessionStore/, RuleStore/, ActionStore/, SourceMapFileStore/ (maps loaded from files) (each a store with its file and record helpers), SettingsStore.ts, + store/ OverrideStore/, SessionStore/, RuleStore/, ActionStore/, SourceMapFileStore/ (maps loaded from files), ShotStore/, BrowserStore/ (each a store with its file and record helpers), SettingsStore.ts, WindowStore/ (an own window's place: the website's, the Actions panel's), WriteQueue.ts, writeAtomic.ts, parseInput.ts (untrusted input read with a schema) and shared sanitizers update/ UpdateService/ (checks, downloads, installs: §10.1), electronInstaller/ (electron-updater), updateEndpoints.ts (GitHub, or a local update server in tests) @@ -166,7 +187,8 @@ src/ pages/editor/ the workspace layout and its persisted layout store; session sync and workspace switching pages/page-window/ the website's own window: the preview alone, toolbar and all (§7.1) pages/actions-window/ the Actions panel's own window (§6.9) - widgets/ title-bar, activity-bar, explorer, editor-panel, page-preview, status-bar, settings-panel, command-palette, + widgets/ title-bar, activity-bar, explorer, editor-panel (+ a shot's, compare and group pages), page-preview (+ the + shots menu and the overlay bar), status-bar, settings-panel, command-palette, console-panel, actions-panel, network-panel, inspect-panel (the Inspect view, with the Components tree), renders-panel (+ by component), stores-panel features/ open-resource (also original sources and the jumps between them and bundles), save-override, @@ -178,7 +200,9 @@ src/ toggle; delete), action/ (a group: run, edit, detach), edit-response-rule, network/ (a group: filter, clear, breakpoints, held, throttle, har, response-tree), inspect/ (a group: stack, the Page stack; pick, picking an element, the Component page and setting - state; tree, the Components tree; renders; stores) + state; tree, the Components tree; renders; stores), browser/ (a group: open-in-browser, the browser + menu with driven and everyday tabs; manage-browsers, Settings › Browsers), shot/ (a group: capture, + open-shot, manage, import-design, compare with its diff worker, overlay) entities/ page, settings, override, editor-tab (+ Monaco model registry, page tabs and whether they hold edits, read-only source tabs), resource, source-map (each bundle's map state, the originals tree), app-update (updater state, the bundled CHANGELOG.md), workspace (+ its rail tile), @@ -186,20 +210,25 @@ src/ rule (+ hit counts and recent requests, header presets, header change rows), action, network-request, held-request (what breakpoints hold now), page-stack (each frame's libraries), inspector (picking, what is under the pointer, the picked component and where its functions come from, - hook names, the Components tree, the Renders log and its profile, the Stores log) + hook names, the Components tree, the Renders log and its profile, the Stores log), browser (the + browsers, driven and everyday ones, their icons), shot (the shots and the overlay, their lines and + thumbnails) shared/ api (preload bridge), ui (design system), monaco (+ the JSON service and per-model schemas), lib (format and source-map workers, overlays, motion, JSON schemas inferred from a response; the source-map worker also reads hook names off originals), config shared/ (both processes, beside the renderer's) stackLibraries, stateAction/ (the code of an action that - sets a component's state: the Component page's Save as action, per framework) + sets a component's state: the Component page's Save as action, per framework), overlay/ (the + overlay settings' schema) test/ unit/ matcher, transform, stores, rule validation and matching, engine and PageInterception (fake CDP), - minified heuristic, source maps (finding, loading, header capture) + minified heuristic, source maps (finding, loading, header capture), browsers (finding, driving), + shots, BiDi answering, everyday Firefox tabs renderer/ resource tree building, palette fuzzy matching, session, workspaces, rules, original sources (parsing, positions through pretty-printing, tabs, jumps, tree), the Network panel, response tabs, held requests and breakpoints integration/ engine, rules, iframe and worker sessions against real Chromium + fixture site, source maps - in every form, the network log, response overrides, unsent answers and breakpoints + in every form, the network log, response overrides, unsent answers and breakpoints; captures, + the overlay, a driven Chromium, a driven Firefox (where Firefox is found) e2e/ the built Electron app driven by Playwright smoke/packaged.ts a packaged build (installed app) driven over the remote debugging port smoke/update.ts an installed app updated to a newer build from a local stand-in for GitHub @@ -211,7 +240,9 @@ test/ (networkPages.ts, /network/: JSON, GraphQL, an event stream, a failing call, a worker's fetch) fixtures/apps/ small React and Vue apps the inspector's tests bundle (the page stack's, a cart per framework, and carts whose state is in Redux Toolkit, Zustand, Pinia and Vuex, and NgRx) - helpers/ Chromium launcher with the app's flags, WebSocket CDP harness, bundleApp.ts (a fixture app + fixtures/firefox/ a session file Firefox wrote (two blank tabs), for the everyday tabs' reader + helpers/ PNG decode and encode, a `.jsonlz4` writer, stopping processes by command line, Chromium + launcher with the app's flags, WebSocket CDP harness, bundleApp.ts (a fixture app bundled with Vite: minified with a source map, or a development build; React, Vue 3 and 2, Angular (compiled in the page), Lit) build/ app icon (icon.png 1024 px original, icon.icns macOS, icon.ico Windows, icons/ Linux sizes), @@ -315,6 +346,31 @@ interface ConsoleAction { // code kept to run in a frame with one click: see createdAt: number; updatedAt: number; } // Each action belongs to one workspace (`workspaceId` in actions.json). + +interface Shot { // a capture of the page, or a design to check it against: see §6.17 + id: string; // 8 hex chars + kind: 'capture' | 'design'; + name: string; // shop.test-cart-1440-full.png; unique in its workspace, renamed in place + width: number; height: number; // pixels in the file + scale: number; // pixels per CSS pixel: 2 for a capture on a 2× screen, or a design exported at 2× + pageUrl: string | null; // a capture's address + browser: { id: string; name: string; version: string | null } | null; // a capture's browser ('app': the app's page) + viewport: { width: number; height: number } | null; // a capture's window, in CSS pixels + area: 'viewport' | 'page' | 'element' | null; + group: string | null; // captures taken together in every browser share one + createdAt: number; updatedAt: number; +} +// Each shot belongs to one workspace (`workspaceId` in shots.json); at most 500 per workspace, 50 MB per file. + +interface OverlaySettings { // the design over the page: see §6.18 (not kept on disk) + opacity: number; // 0–1 (0.5) + blend: 'normal' | 'difference'; + invert: boolean; + x: number; y: number; // where it sits, in CSS pixels + attached: 'page' | 'viewport'; // scrolls with the page, or stays put + hidden: boolean; + fitWidth: boolean; // the page laid out at the design's width (on) +} ``` Originals opened from source maps (§6.8) are tabs of their own kind, kept apart from file tabs as pages are: they are read-only, never saved or restored, and close with the workspace. @@ -327,6 +383,11 @@ rules.json { version: 1, rules: (Rule & { workspaceId })[] } files/. served content files/.base. diff base (only when it differs from the content) actions.json { version: 1, actions: (ConsoleAction & { workspaceId })[] } // every workspace's, oldest first +shots.json { version: 1, shots: (Shot & { workspaceId, ext })[] } +shots/. a shot's image, as captured or imported +shots/.thumb.jpg its thumbnail: the top of it, 256 px wide +browsers.json (in ) { version: 1, added: { id, name, path, engine }[], hidden: string[] } // Settings › Browsers (§6.16) +browsers// (in ) the profile of a browser the app drives (§6.16) settings.json (in ) Settings page-window.json (in ) { detached, bounds?, maximized? }: whether the website has its own window, and where (§7.1) actions-window.json (in ) { detached, bounds?, maximized?, onTop? }: the same for the Actions panel (§6.9) @@ -678,6 +739,77 @@ While store actions are recorded, every action the page's stores handle in every **UI.** The bottom pane's **Stores** tab (a dot while recording): the actions newest first (the last 1,000 kept, 200 drawn, only those in view drawn, each measured as it is), each with its number, time, frame, store (its Pinia id, its connection's name, or Redux), type, payload, how long the store took (Redux's reducers, Pinia's actions), and a link to the app's call that dispatched it; then what it changed, and the whole stack on request. **Record store actions** in the palette starts it. The page stack names Redux when a store was created through the stand-in. +### 6.16 Other browsers + +The page can be opened in the browsers installed on this computer, to check whether something is broken there. The research, and what is still to come, is in [BROWSERS_RESEARCH.md](BROWSERS_RESEARCH.md). + +**Finding them** (`src/main/browsers/findBrowsers/`), looked for at most once a minute and sorted by name, the ones the user added after them: +- **Linux:** desktop entries in `XDG_DATA_HOME`, Flatpak's exports (the user's and the system's), `XDG_DATA_DIRS` and Snap's, the first entry of an id winning. An entry counts when it is in the `WebBrowser` category, or handles `text/html` and http(s). Entries that are hidden, run in a terminal, or are the app itself are skipped, and its program (`TryExec`, or `Exec`'s) must be runnable. `Exec` is split as the spec says: `%u %U %f %F` mark where the address goes, other field codes are dropped. Icons come from the icon theme (`hicolor` and `pixmaps`), PNG scaled to 64 px or an SVG of at most 256 KB. +- **macOS:** the `.app` bundles in `/Applications` and then `~/Applications` whose name is a browser's the app knows (by engine), started with `open -a`; their icons from the system. +- **Windows:** the registry's `StartMenuInternet` clients (the machine's, the user's and 32-bit ones), each once, started by their `shell\open\command`; their icons from the system. +- **Engines** are told from the name and program (`chromium`, `gecko`, `webkit`, else `unknown`). **Versions** are read one at a time after a scan and announced once all are in: `CFBundleShortVersionString` on macOS, the file's product version on Windows, `--version` on Linux (through `flatpak run` for a Flatpak), with a 5-second limit each. +- **Added browsers:** Settings › Browsers adds a program (or a macOS app) picked with the system's dialog; switching a browser off there stops offering it. Both are kept in `browsers.json`. + +**Opening the page** (the browser menu beside the address bar, and the palette's **Open in …**) starts the browser's own command with the address, on its own (it outlives the app), with its everyday profile. Only http(s) pages are opened. The menu's button shows up to four browsers' icons; the menu searches them, and Enter opens the first match. + +**With your changes** (**Open in … with your changes**, for Chromium browsers and Firefox: `DRIVEN_ENGINES`) launches the browser with a profile of the app's own, and serves the active workspace's overrides and rules in every tab it has (`src/main/browsers/driven/`): +- **The profile** is `/browsers/`; for a Snap it is `~/snap//common/console-editor-profile`, and for a Flatpak `~/.var/app//data/console-editor-profile`, the folders their sandboxes let them write. Logins stay there between launches. Chrome 136 and later ignore a debugging port on the everyday profile, which is one more reason for a profile of its own. +- **Launching:** Chromium gets `--user-data-dir`, `--remote-debugging-port=0`, `--no-first-run`, `--no-default-browser-check` and the app's own Local Network Access switches, and writes the port in `DevToolsActivePort`. Firefox gets `--profile`, `--no-remote`, `--new-instance` and `--remote-debugging-port=0`, with a `user.js` that skips its first-run pages, the default-browser question and data reporting, and writes its WebDriver BiDi address in `WebDriverBiDiServer.json`. On macOS a new instance is started (`open -n -a … --args`); for a Flatpak the flags go before its forwarded-file markers. Both start on a blank page. +- **Reaching it again:** a browser still open with that profile (the app was restarted, or let go of it) is reached through the address in its profile rather than launched twice: a second launch would hand its address to the running browser and never open a port. +- **Chromium** is driven over CDP: a browser-level connection auto-attaches every tab (a new one waits before it loads anything) and gives each one a `PageInterception` of its own, the engine the app's page uses (§6.1–§6.6). Other targets are let go of as they came. +- **Firefox** is driven over WebDriver BiDi (`src/main/engine/bidi/`): one interception for every tab pauses requests while an override or a rule is on, and bypasses the cache then. Before a request is sent, a block rule fails it, a CORS preflight ahead of a request an override answers is allowed, and an override answers it (`src/main/engine/answering/`). Firefox can only replace a body at that point, so the override's headers are its kind's (content type, `no-store`), a response override's status, header changes and delay, readable cross-origin, then the header rules. At the response's head, header and CORS rules change it. An override naming a GraphQL operation answers by the request's body, which Firefox keeps for the app (a data collector, bodies up to 1 MiB) only while such an override is on; an older Firefox that can't keep them sends those requests on. Its preflight is allowed whatever operation it names. What this leaves out in Firefox: patch mode (the saved text is served), redeploy detection, an overridden script behind an `integrity` attribute unless the page's HTML is overridden too, and breakpoints. +- **Keeping them in step:** overrides, rules and settings that change reach the driven tabs at once, and with **Reload page after changes** on, their tabs showing a website reload (after a burst, once). Nothing is reported from them: the console, Network panel and inspector stay the app page's. +- **Tabs:** the menu lists each driven browser's tabs, with their titles (read when a page has loaded and whenever the menu opens, since neither protocol announces a title) and addresses. A tab can be brought to the front, loaded in the app, or captured. The × lets go of the browser, which stays open. A browser the user quits is forgotten, and the status bar counts the tabs served your changes. +- **The design** laid over the app's page (§6.18) goes over every driven tab too, as it changes, and over each new document: Chromium through the same isolated-world script, Firefox through preload scripts in a sandbox (`script.addPreloadScript`), each tab laid out at the design's width while that is on (CDP's device metrics, BiDi's viewport). A tab's capture lays it aside, as the app's own does. + +**Your everyday Chrome** (the row's second button, **Use your own … with your changes**; `src/main/browsers/driven/everyday/`): a Chromium browser running on its everyday profile with remote debugging turned on (Chrome 144's `chrome://inspect/#remote-debugging`) writes the address of its port in that profile's `DevToolsActivePort`. The profile folders are known per system and channel (Chrome and its betas, Chromium, Edge, Brave, Vivaldi, Opera, Arc; Snap's and Flatpak's own folders too). While the browser listens there (a TCP connection is tried, nothing is asked of it), the registry lists it as `debuggable` and the menu offers it; connecting waits up to 60 s, since Chrome asks you to allow it. Only the tabs the app opens there (`Target.createTarget` and `attachToTarget`) are served the workspace's changes and listed; your own tabs are left alone. It is listed as ` · your profile`, and letting go of it leaves the browser running as it was. The permission prompt itself couldn't be checked here: the development container's Chromium (141) predates it, so a Chromium started on a stand-in everyday profile with a debugging port plays the part in the tests. + +**WebKit** (`src/main/browsers/webkit/`, `src/main/browsers/driven/playwright/`): Safari can't be served changes, so the app offers Playwright's WebKit build as a browser of its own on every system (**WebKit**, with its version), opened with your changes only. +- **The download:** the first time, it asks to download it (about 90 MB), then fetches it from where the pinned `playwright-core`'s registry says (its mirrors, or `PLAYWRIGHT_DOWNLOAD_HOST`'s), its progress in its row (`browser-download`), into `/browsers/playwright/webkit-`. It is unpacked with Playwright's own extractor, its program made runnable and Playwright's marker written last, as Playwright's installer does; the app can't run that installer, which starts Node as a child process, which the packaged app's fuses refuse. A download that fails leaves nothing behind. Settings › Browsers removes it. On Linux it needs system libraries (`npx playwright install-deps webkit`); a launch that fails says what the browser said. +- **Driving it:** `playwright-core` launches it (in a window of its own) with the cookies and storage it kept last time. Every request goes through `route`: `decideRequest` fails, answers or sends it on (its body known, so an override naming a GraphQL operation answers there too), and one a response rule takes is fetched first (`route.fetch`) to change its head. A capture at a viewport is taken in a context made for it (a density is a Playwright context's). The design is laid over its tabs by init scripts in the page's own world (Playwright has no other), and its width through the viewport. +- **It can't outlive the app's hold on it:** letting go of it quits it, keeping its cookies and storage for next time. +- **Packaging:** `playwright-core` is a dependency of the app, pinned (the registry and extractor the app uses are its internals), left out of the main bundle (it reads its own files by the paths it computes) and shipped in the asar as it is, loaded when WebKit is first used. +- Tested with Playwright's Chromium standing in for WebKit's build, which can't be downloaded here (the driver speaks only Playwright's API), and the download against a stand-in host; in the packaged app, the download and a launch were checked against a stand-in build. + +**Your everyday browsers' tabs** (`src/main/browsers/everyday/`): with a browser installed whose tabs can be listed (`listsTabs`: Firefox; on macOS also Safari, Chrome, Edge, Brave, Arc and Vivaldi, with their betas), the menu offers **Your open tabs**. Once asked, and then each time the menu opens in that run, it reads them: +- **Firefox**, on every system: every profile's session file (`sessionstore-backups/recovery.jsonlz4`, which Firefox keeps up to date as it runs): `profiles.ini` in Firefox's folder on each system (Snap and Flatpak ones too) names the profiles, the install's default first. The file is an LZ4 block behind a `mozLz40` header, decoded by the app's own reader. +- **On macOS**, the installed browsers scripting reaches, by their app's name, in one JavaScript for Automation script (`osascript -l JavaScript`, 60 s at most): an app that isn't running is left alone (never started); a running one's windows give their tabs' addresses and titles in two requests each (a window without tabs, as Safari's settings, is skipped), as JSON. The first time, macOS asks whether the app may control that browser (`NSAppleEventsUsageDescription`, and the `automation.apple-events` entitlement); refused, the browser is listed with how to allow it (System Settings › Privacy & Security › Automation). Their tabs aren't one profile's, so they are listed under the browser's name alone. Checked against a stand-in for JXA's objects in the unit tests: there is no Mac to run it on here. + +Each tab's current page is listed when it is on the web, and choosing one loads it here. + +### 6.17 Captures and designs + +A workspace keeps **shots**: captures of the page, and designs to check it against (`ShotStore`, `PageShots`). + +**Capturing** (the shots menu's **Capture**: what the page shows, the whole page, an element, in every browser; the palette; a component's page) goes over CDP (`src/main/shots/capture/`): +- Waits up to 2 s for the page's view to be on screen (a menu over it shows a still), and lays the design aside while it captures (§6.18). +- **What the page shows:** `Page.captureScreenshot`, scrollbars included. +- **The whole page:** the document rendered past the viewport (`captureBeyondViewport`, clipped to its size), which leaves the viewport and scroll as they were, down to 32 767 device pixels, the tallest image a canvas draws (so it can still be compared). Past Chromium's largest texture (16 384 device pixels) a clip comes back cut or blank, so a taller one is captured in parts 4096 device pixels tall (a whole number of device pixels, so they meet on one), one after another, and joined (`src/main/shots/png/`): their rows are packed again as one PNG still filtered, only each part's first row changed to read nothing above it, one part unpacked at a time, off the main thread. A page that tall, at twice the density and full of gradients, takes about 45 s (half of it Chromium drawing the parts) and may come out over the 50 MB a shot can be. An element taller than a texture is captured the same way, and Firefox's page captures too. +- **An element:** the inspector's pick (§6.13), its border box (`DOM.getBoxModel`) moved by each cross-site frame's place in its parent (`DOM.getFrameOwner`, asked of the session that has it) and by the scroll, without the inspector's highlight. +- A capture's **scale** is its pixels per CSS pixel, and its viewport the window's size in CSS pixels (`innerWidth`, `innerHeight`, read in the page). It is named after its address, viewport width and what it covers (`shop.test-cart-1440-full.png`), and gets a 256 px JPEG thumbnail of its top. +- A **driven browser's tab** is captured the same way over its own protocol (brought to the front first, since a hidden tab isn't drawn), with the browser's name and version. + +**In every browser** captures the whole page in the app, then in every driven browser at once at the same address, in the tab showing it (or one opened there). Each is laid out at the app's viewport and density (`Emulation.setDeviceMetricsOverride`, BiDi's `setViewport`) and captured once it has loaded and been quiet for 500 ms (30 s at most). The captures are kept as one **group**; a browser that fails is named in the notice. The group's page (an editor tab) shows them side by side against a **baseline** (the app's capture at first, another of them, or a design), each with the share of its pixels that differ, or the difference itself. Choosing one opens the two in the compare page. + +**Designs** are imported with the system's dialog, dropped or pasted on the shots menu: PNG, JPEG or WebP of at most 50 MB, told by their bytes, kept as they came. A design's scale comes from `@2x`/`@3x` in its name, else 2 when it is wider than 2000 px, else 1, and can be changed on its page. + +**A Figma frame** comes in by its link (the shots menu's Figma button; `src/main/figma/`): Share › Copy link with a frame selected, or the address bar's, gives the file's key (a branch's own for a branch) and `node-id` (`12-34`, which the API writes `12:34`); `figmaImportSchema` (`src/shared/figma/`) checks it in the form and in the main process. With a personal access token (`X-Figma-Token`), the main process asks Figma's API for the frame's name (`/v1/files/:key/nodes`), then for its render as PNG at 2× (`/v1/images/:key?format=png&scale=2`), downloads that from where Figma put it (without the token) and keeps it as a design named after the frame, `@2x` (so its scale is 2). Figma's refusals are said in words (a token refused, a file it can't open, too many requests). The token is kept only once it has worked, encrypted by the system (`safeStorage`: Keychain, DPAPI, the keyring) in `figma-token`; where there is nothing to encrypt it with (Linux without a keyring, whose fallback key is known) it is kept for that run only. The form shows it is kept, to change or forget. `CONSOLE_EDITOR_FIGMA_API` points at a stand-in for Figma's API (tests), honoured only with a data folder of its own. + +**The shots menu** (the toolbar's end, in both windows: the latest thumbnails stacked) searches shots by name, address and browser, with All · Captures · Designs. A shot's menu copies it, saves a copy, shows its file, lays it over the page, and deletes it (in the editor). **A shot's page** shows it at fit, 100 % or zoomed in (a pixel grid from 8×), the colour under the pointer, its size and where it came from, and renames it in place. + +**Comparing** (a shot's **Compare with…**: another shot, or the page now captured at the shot's width and scale) opens the compare page: side by side (scrolled together), swipe, onion skin, or the difference. The difference is worked out in a worker: both are drawn at their CSS size; pixels whose YIQ distance is over the threshold, or that only one covers, differ. A pair that differs only as anti-aliasing does (Vyšniauskas's detector, as pixelmatch has it: in either image, the pixel has fewer than three neighbours of its own colour, both darker and brighter ones, and the darkest or the brightest of them sits in a flat area of both images) is shown yellow and not counted, since two browsers smooth the same edges differently; on 1440 × 10,000 pixels with a tenth of them differing that takes about 4 s against 2 s without (`test/perf/renderer/diff.perf.test.ts`). The rest are shown red over the faded base, with the share of pixels that differ (and, apart, the share that is only anti-aliasing), and the areas that differ (16 px cells joined, at most 200) to step through. The top image can be moved (Shift for 10 px) to line them up. + +Images are served to the UI by the `console-editor-shot:` protocol, on the app's own session only (`image/` and `thumb/`); their pixels are read over IPC, since the protocol's images would taint a canvas. + +### 6.18 The design over the page + +**Put over the page** (a shot's page and menu) lays a design over the app's live page (`src/main/overlay/`), with a bar under the toolbar (both windows): opacity, **Difference**, x/y (the arrows while the bar has focus, Shift for 10 px), scrolling with the page or fixed, the page laid out at the design's width, Hide and Take off. +- The design is drawn on a canvas by a script in an isolated world (`Page.addScriptToEvaluateOnNewDocument` with a world name, and `Page.createIsolatedWorld` of that name for the document already there), so a strict CSP can't block it and the page's scripts can't reach its code. It is put back if the page removes it, and follows every load of the page. It never takes the pointer. +- **Difference** needs a backdrop: a page with no background of its own is given a white one while it blends, and gets its own back afterwards. +- **At the design's width:** the page is laid out that many CSS pixels wide and scaled down to fit the view, as DevTools' device mode does (Electron's `enableDeviceEmulation`: CDP's scale draws wrong in a `WebContentsView`), again as the view resizes. +- Captures lay it aside and give the page its own width while they run. It goes when its shot is deleted or another workspace is shown. +- It goes over the tabs of browsers driven with your changes too (§6.16). The script shows itself only in a top document, since it runs in every frame's. + ## 7. Editor behaviour | Action | Behaviour | @@ -740,6 +872,12 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g - The store stand-in puts the Redux DevTools extension's globals in pages that have none (only while **Framework hooks** is on): a page can tell, and a store made through them keeps working when nothing records (the stand-in's dispatch only notes the action). Its own global (`__consoleEditorStoreHook`) only finds a frame's Vue stores and says which libraries it heard of. Store names, action types, payload previews and stacks are checked like any answer; a stack's calls must be in http(s) files. - An action saved from the Component page is code the app wrote, shown in the action form before it is kept: the page's selectors are in it as JSON strings, and the value as the JSON you typed. - Rules change only what the app's own browser sees. A CORS rule makes an API readable by the page shown, credentials included, which is what it is for; it can't change what the server allows, and cookies still follow the browser's rules. +- Other browsers are started as the user's own programs, found where the system lists them or picked by the user, with an http(s) address only. A browser driven with your changes has a profile of the app's own (never the everyday one) and a debugging port on `127.0.0.1` that the system picks; like any debugging port, a local program can connect to it while the browser runs. The app reaches it only through the address that browser wrote in that profile, and lets go of it (the browser stays open) when asked or when the app quits. +- A driven browser's tabs are served the workspace's overrides and rules, as the app's page is; nothing is read back from them but their titles, addresses and captures. Your everyday Chrome is reached only once you turned remote debugging on for it, and Chrome asks you before the app connects; only the tabs the app opens there are served your changes. +- The WebKit build is downloaded only once you agree, from Playwright's hosts over HTTPS (or `PLAYWRIGHT_DOWNLOAD_HOST`, Playwright's own setting for a mirror), into the data folder; its zip is unpacked by Playwright's extractor, which refuses entries outside the folder. +- Your everyday Firefox's session file (it holds your open tabs, and more), and on macOS your other browsers' tabs through scripting, are read only after you ask for them in the browser menu, in the main process; only each tab's current http(s) address and title reach the UI, and nothing of it is kept or sent anywhere. +- A Figma token goes only to Figma's API (never to the address its render is downloaded from), is kept encrypted by the system and only once it has worked, and can be forgotten from the form; without a way to encrypt it, it isn't written at all. +- Shots are images the app took or the user imported (checked by their bytes), served to its own UI by the `console-editor-shot:` protocol on its own session only; the site's session can't load them. The design laid over the page lives in an isolated world the page's scripts can't reach, and takes no pointer events; the page can still see the element in its DOM, and it is put back if removed. - The site sees a standard Chrome user agent (Electron tokens removed). - All data stays local: nothing is uploaded, and there is no telemetry. Besides the page and out-of-page fetches for the files you open and for the page's favicon (from the site shown, through its session; kept only if its bytes are an image, and shown as an `` data URL, where SVG can't run scripts), the source maps of the page's scripts and stylesheets when you ask for them (§6.8: http(s) through the site's session, with cookies only for the bundle's or the page's origin; `data:` maps decoded locally; any other scheme refused; 64 MB, 30 s), the only other network calls are the update check (GitHub's releases API and the release's CHANGELOG.md at its tag) and, when you ask for it, the update's download. **Settings › Check for updates** turns the automatic check off. - Updates are verified before they are installed: electron-updater checks the SHA-512 in the release's `latest*.yml`, and manual downloads are checked against the release's `SHA256SUMS.txt` before they are kept. Both come from the same GitHub release, so this guards against damaged downloads, not a compromised release; signed builds would add that (§11). Release notes are Markdown rendered with `marked` and sanitized with DOMPurify (no images, styles, forms or frames), and their links open in the default browser (http and https only). `CONSOLE_EDITOR_UPDATE_FEED` (a local update server, for tests) is honoured only with a data folder of its own, like the debugging port. @@ -748,18 +886,18 @@ The site's `WebContentsView` can move to a window of its own (`PageWindow`), e.g | Layer | What | Command | |---|---|---| -| Unit | Matchers, header/SRI/source-map transforms, stores (persistence, atomic concurrent writes), engine logic and navigation rules with a fake CDP transport, iframe session coordination (timeouts, sessions that go away, cascading detach), worker sessions (setup order and resuming, `Fetch` kept on, settings per worker type, which session lists and credits a worker's files, missed-override reasons, outdated service workers and `prepareReload` (unregistering by scope, letting go, retrying), a service worker's state kept for its next session, shared-worker discovery and holding), minified heuristic, version comparison, CHANGELOG parsing (and that CHANGELOG.md covers `package.json`'s version), the update service with fakes (checks, quiet failures, progress, checksums, install failures, schedule), the Linux desktop entry (quoting paths, which copies install one, a package's entry winning, removing only its own, following a renamed AppImage, writing only what changed) and the `.rpm`'s install script kept in step with electron-builder's, workspaces in the stores (migration, per-workspace tabs, drafts, overrides and actions, deletion, frame names), the action store (each workspace's actions across instances, what it refuses and why, a failed write changing nothing, changes run in order), favicon loading (recognising images, size caps), the console service with a fake CDP transport (frames across sessions, contexts, rows and their previews, format directives, running code, properties, the cap, clearing, the setting), the session observer (handed every session before it runs, never holding interception up) and where the website's and the Actions panel's windows open (its saved place while it is on a screen, another screen than the editor's, fitted to a small one, at the size each opens at) and how that is saved (Keep on top too), rules (validation, the store's persistence, entries it can't read, broken and unreadable files, the engine's stages, header edits, CORS and preflights, request types, patterns), source maps (finding the reference, loading limits, header capture), response overrides that don't send (`unsentOverrides.test.ts`: patterns, the answer and its CORS, the preflight, what goes on to the server, the delay, the setting kept) and breakpoints (`breakpoints.test.ts`: validation and storage, patterns, holding at each stage and each action, what never stops, letting go), the JSON tree (`jsonTree.test.ts`: parsing without loss, offsets, what it refuses, diffing and applying edits, giving up on a document they don't fit), patch mode (`patchMode.test.ts`: the live response patched, edits worked out once per version, the saved text when upstream fails or the edits don't fit, never a stream, the setting kept), HAR files (`har.test.ts`: entries written, sockets' messages, overrides read back and what is left out), the network speed setting (kept, checked) and its commands on every kind of session, the page stack (the React hook stand-in in every session, with `Page` on first, taken out and put back with the setting; a frame looked at once it loads, silently in its main world, once for two loads in quick succession, not after it navigated, and not kept from the document it left; a cross-site iframe's own session, dropped with it; nothing while the console doesn't record, every frame once it does; the console unaffected when the stand-in can't go in; what the page answers checked: unknown ids and signals, builds, versions, at most 40); the component inspector with a fake CDP transport (`componentInspector.test.ts`: every session into inspect mode and out, one that attaches while picking too; what is under the pointer read at most every interval, the latest node winning; a click stopping picking, keeping the node as `$0` and sending its component with each function placed; a node's frame told by its frame owner; the chain's other components; Esc in the page; the last 20 picks kept and older handles freed; highlighting, with the Overlay domain off once picking and highlights are over; a node that can't be read; a production Angular page's view registry found and handed to the adapter, and taken from the page the next time; the element's own listeners, named and placed; what the page says checked: frameworks, builds, state kinds, labels, list caps); scripts' source maps counted per frame (inline, isolated-world, evaluated and other frames' scripts left out); renders, the tree and state (`componentRenders.test.ts`: the binding in every session while recording, one attaching too, removed and its function deleted when it stops; batches sent in order, checked, framed by their context, their functions located once per document; other bindings, bad payloads and anything after stopping ignored; the page's commits checked: kinds, reasons, labels, caps; a tree level read in the frame's main world, a node opened as a pick at its depth and highlighted, a gone frame or bad path refused; a state edit taken only as a known kind, a name and JSON); source maps loaded from files (`sourceMapFileStore.test.ts`: a copy kept per workspace and bundle and read back after a restart, replaced, forgotten, removed with its workspace, its copies deleted; index entries that don't hold up, a copy named outside the folder among them, left out; a file's map handed over as found against the bundle as served); store actions (`storeTimeline.test.ts`: the hooks' one script, which parses; the binding in every session while recording, each frame asked to find its Vue stores, taken out when it stops; batches checked, numbered and framed, others' and late ones ignored, a flood of them capped; known libraries, labels, stacks in loaded files and caps), the events sent to a window as JSON (`appEventWire.test.ts`), the main process's lookups bounded (`inspectorLookups.test.ts`: only the scripts alive kept, whether each names a map, long URLs left out; at most 1,000 functions placed a read, one gone leaving the others; initiators' URLs and names), commits naming their store action and components their own render time, a request's initiator through its async parents in loaded files (`networkLog.test.ts`) | `npm test` | +| Unit | Matchers, header/SRI/source-map transforms, stores (persistence, atomic concurrent writes), engine logic and navigation rules with a fake CDP transport, iframe session coordination (timeouts, sessions that go away, cascading detach), worker sessions (setup order and resuming, `Fetch` kept on, settings per worker type, which session lists and credits a worker's files, missed-override reasons, outdated service workers and `prepareReload` (unregistering by scope, letting go, retrying), a service worker's state kept for its next session, shared-worker discovery and holding), minified heuristic, version comparison, CHANGELOG parsing (and that CHANGELOG.md covers `package.json`'s version), the update service with fakes (checks, quiet failures, progress, checksums, install failures, schedule), the Linux desktop entry (quoting paths, which copies install one, a package's entry winning, removing only its own, following a renamed AppImage, writing only what changed) and the `.rpm`'s install script kept in step with electron-builder's, workspaces in the stores (migration, per-workspace tabs, drafts, overrides and actions, deletion, frame names), the action store (each workspace's actions across instances, what it refuses and why, a failed write changing nothing, changes run in order), favicon loading (recognising images, size caps), the console service with a fake CDP transport (frames across sessions, contexts, rows and their previews, format directives, running code, properties, the cap, clearing, the setting), the session observer (handed every session before it runs, never holding interception up) and where the website's and the Actions panel's windows open (its saved place while it is on a screen, another screen than the editor's, fitted to a small one, at the size each opens at) and how that is saved (Keep on top too), rules (validation, the store's persistence, entries it can't read, broken and unreadable files, the engine's stages, header edits, CORS and preflights, request types, patterns), source maps (finding the reference, loading limits, header capture), response overrides that don't send (`unsentOverrides.test.ts`: patterns, the answer and its CORS, the preflight, what goes on to the server, the delay, the setting kept) and breakpoints (`breakpoints.test.ts`: validation and storage, patterns, holding at each stage and each action, what never stops, letting go), the JSON tree (`jsonTree.test.ts`: parsing without loss, offsets, what it refuses, diffing and applying edits, giving up on a document they don't fit), patch mode (`patchMode.test.ts`: the live response patched, edits worked out once per version, the saved text when upstream fails or the edits don't fit, never a stream, the setting kept), HAR files (`har.test.ts`: entries written, sockets' messages, overrides read back and what is left out), the network speed setting (kept, checked) and its commands on every kind of session, the page stack (the React hook stand-in in every session, with `Page` on first, taken out and put back with the setting; a frame looked at once it loads, silently in its main world, once for two loads in quick succession, not after it navigated, and not kept from the document it left; a cross-site iframe's own session, dropped with it; nothing while the console doesn't record, every frame once it does; the console unaffected when the stand-in can't go in; what the page answers checked: unknown ids and signals, builds, versions, at most 40); the component inspector with a fake CDP transport (`componentInspector.test.ts`: every session into inspect mode and out, one that attaches while picking too; what is under the pointer read at most every interval, the latest node winning; a click stopping picking, keeping the node as `$0` and sending its component with each function placed; a node's frame told by its frame owner; the chain's other components; Esc in the page; the last 20 picks kept and older handles freed; highlighting, with the Overlay domain off once picking and highlights are over; a node that can't be read; a production Angular page's view registry found and handed to the adapter, and taken from the page the next time; the element's own listeners, named and placed; what the page says checked: frameworks, builds, state kinds, labels, list caps); scripts' source maps counted per frame (inline, isolated-world, evaluated and other frames' scripts left out); renders, the tree and state (`componentRenders.test.ts`: the binding in every session while recording, one attaching too, removed and its function deleted when it stops; batches sent in order, checked, framed by their context, their functions located once per document; other bindings, bad payloads and anything after stopping ignored; the page's commits checked: kinds, reasons, labels, caps; a tree level read in the frame's main world, a node opened as a pick at its depth and highlighted, a gone frame or bad path refused; a state edit taken only as a known kind, a name and JSON); source maps loaded from files (`sourceMapFileStore.test.ts`: a copy kept per workspace and bundle and read back after a restart, replaced, forgotten, removed with its workspace, its copies deleted; index entries that don't hold up, a copy named outside the folder among them, left out; a file's map handed over as found against the bundle as served); store actions (`storeTimeline.test.ts`: the hooks' one script, which parses; the binding in every session while recording, each frame asked to find its Vue stores, taken out when it stops; batches checked, numbered and framed, others' and late ones ignored, a flood of them capped; known libraries, labels, stacks in loaded files and caps), the events sent to a window as JSON (`appEventWire.test.ts`), the main process's lookups bounded (`inspectorLookups.test.ts`: only the scripts alive kept, whether each names a map, long URLs left out; at most 1,000 functions placed a read, one gone leaving the others; initiators' URLs and names), commits naming their store action and components their own render time, a request's initiator through its async parents in loaded files (`networkLog.test.ts`); PNG parts joined whatever their rows' filters (`stitchPngs.test.ts`) and a clip cut into parts; a paused BiDi request's body read and let go of; your macOS browsers' tabs, the script run against a stand-in for JXA's objects; Figma links, the token kept encrypted and the import against a stand-in API (`figma.test.ts`); the WebKit build's download from a stand-in host (`webkitDownload.test.ts`) | `npm test` | | Renderer | Resource tree building and filtering (worker entries: kept across navigations for service and shared workers, dropped with their worker, filtered by worker URL), command-palette fuzzy matching, missed-override notices by reason (a nested worker's once per override version), update notifications, What's New and page tabs, session sync and workspace switching (pending drafts written, tabs closed without losing them, the other workspace's reopened), console frames (keys, labels, colours), rows, filters, Keep rows, prompt history and frame names, rules (quick rules and Undo, toggling with rollback, rule pages and their forms, applying only what changed, results that arrive after a switch, hits and recent requests), original sources (parsing, positions through pretty-printing, tabs, jumps both ways, the tree, the palette), actions (finding an action's frame by key or by name, labels of frames not on the page, runs and why one couldn't run, the form's openings, saving and deleting, copies, the palette's group, the bridge loading and following them), the Actions window's bridge (what it loads, the events it follows and those it ignores) and the editor's copy of where the panel is and of settings another window changed, the Network panel (rows kept and replaced, earlier loads dropped unless kept, groups and the text filter, status, size and time cells, which actions a request offers) and response overrides (the inferred schema, which override answers a request, opening a response and saving it with its method, operation and answer, the response row's fields and Apply), the bottom pane's tab, held requests (`held.test.ts`: their tabs, what Send does at each stage and with an edited body, fields that can't go out, Send original, Fail, Save as override, closing the tab, the page giving up, never kept in the session), breakpoints (added, toggled, removed, refused, put back when saving fails, Pause like this) and Copy as fetch, quick edits (`quickEdits.test.ts`: lists emptied, texts lengthened but links, ids and dates, the value at the cursor nulled, the rest of the text as typed) and Patch live through the response row's form, the response tree (`responseTree.test.ts`: rows for what is open with unique ids, members removed with their comma, added as the last one is written, the selection after a rename or removal), the page stack (UI libraries named once, the top page's first; findings by kind; one Page stack tab; the bridge's snapshot and `stack-changed`), the component inspector (`componentInspector.test.ts`: a function's place in the bundle mapped to its original, named by the map or read off the original's text in each way code names a function; a React component named after its original, a Vue one by itself; picking toggled as main last said; another component of the chain read, or a toast when the page moved on; the bridge following picking and showing what was picked, in one Component tab renamed in place); hook names read off an original (`hookNames.test.ts`: hook calls laid out as React keeps them, same-file custom hooks followed, an imported one stopping the naming, arrows placed by their parameters); renders, the tree and state (`componentRenders.test.ts`: why a component took part, in words, hooks named; triggers; what a frame's last commit rendered; the log's cap; the bridge following recording and tracing functions once each; tree rows as far as open; a frame's top level, a node opened once and closed, a stale frame's level dropped, a pick revealed down its path; opening a node, and a toast when it can't; setting a value, and a toast when it can't); maps loaded from files (`mapFiles.test.ts`: location keys read back; a bundle's traced places forgotten and traced again once a map is loaded or forgotten, every original and hook name forgotten with the maps and a lookup finishing after dropped, nothing when no file was picked, toasts on failures; the Explorer's menu items); store actions (`storeTimeline.test.ts`: recording followed, actions kept and capped, their calls traced once each, a failure to start said; the app's own call in a stack, past libraries); requests by component (`requestsByComponent.test.ts`: a frame's requests sent through a file, newest first, with the call); a commit's trigger with its action, a set value kept and the action written from it per framework (a hook's place, a class's key, selectors through shadow roots, none without a way in), renders by component (counts, own time summed, reasons, costliest first; kept as commits come and go the same as summed afresh; the log's rows) (`componentRenders.test.ts`) | `npm test` | -| Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | +| Performance | The inspector on large apps in real Chromium (`test/perf/inspector.perf.test.ts`): a React and a Vue 3 app of some 14,000 components (a 2,000-row table, a chain 1,500 deep in React, 300 in Vue) and stores of 20,000 items (Redux Toolkit, Pinia), as production builds. It times a commit of every row with the hooks off, idle and recording renders, and after picking; hovering and picking deep in the table and at the end of the chain; the page stack; a level of the tree; a batch's size; Redux dispatches and Pinia actions idle, recording and after it stopped. The compare page's difference on 1440 × 10,000 pixels, anti-aliasing told apart (`test/perf/renderer/diff.perf.test.ts`). And the built app's UI (`test/perf/ui.perf.test.ts`) fed what large apps record at the pace recording sends it (2,000 commits of 200 components, 1,000 store actions with 50-call stacks, 2,000 requests with 20-call initiators, their places traced through a real bundle's map): how long the window's main thread is blocked, its longest task, the memory the log takes, and how long **By component** takes to show. Each prints its medians and fails past a budget set well above what a laptop measures, so only work growing with the whole page on a hot path trips it; it isn't part of CI | `npm run test:perf` (on a display: `xvfb-run -a` on headless Linux) | | Architecture | Feature-Sliced Design layer rules; the code-structure rules (files of at most 150 lines, one function, component, class or store per file named after it, data-only constants/types/index files, no `switch`, no sibling names that differ only in case) | `npm run lint:fsd`, `npm run lint:structure` | -| Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | -| End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | +| Integration | Engine in real Chromium against the fixture site: gzip, static and runtime SRI, globs, CSS/HTML overrides, 404, redeploy detection, source maps, disable. Iframes through the session-aware WebSocket transport (`test/helpers/chromium.ts`): same-site, cross-site and nested iframes, SRI inside iframes, iframe HTML overrides, same-site navigation, removal, reload; each asserts the iframe really is a separate target. The console on a page of service iframes (same-site, and two on sites of their own): every frame's first log line on its own frame, code run in one frame and another frame's logs reacting, top-level `await` and expanding the result, uncaught errors and rejections, a frame keeping its id across a navigation, a cross-site frame's function refused from the top page but run in the frame itself (why actions run in their frame). Workers against the fixture's `/workers/` page (`workers.chromium.test.ts`, a fresh browser context per test): every kind runs while intercepted; edits reach a dedicated and a module worker's first script and imports, what a nested worker imports (its first script is served in Chromium 141, reported as missed in 152+), a shared worker, a service worker's script and imports, and an audio worklet's module; each file is listed with its worker and read through its session; a shared worker's races are forced by holding back its session's commands; workers under a cross-site iframe are served on its session and removed with it; workers are reported gone; an edit to an installed service worker applies on the next reload, and one whose page was left is reinstalled when its site loads again under other overrides (a workspace switch); the page's `registration.update()` is reported and the next reload undoes it; a service worker is listed again, and not reinstalled, when the page comes back to its site; the cache setting reaches what workers load. Rules (`rules.chromium.test.ts`): blocking before the server sees the request (a script, one inside a cross-site iframe, an iframe's document, a redirect's later hop), a URL both blocked and overridden, the page itself never blocked; a gzipped document's CSP removed and another's added, X-Frame-Options removed so a page can be framed, a document no rule changes never read, Cache-Control and Content-Type on streamed responses, headers back once a rule is off; CORS with credentials, a preflight the API refuses, redirects. Source maps named by headers, `X-SourceMap`, comments and `data:` URIs, a stylesheet's, and an override-served bundle's. The network log and response overrides (`network.chromium.test.ts`, the fixture's `/network/` page): what the log lists for fetch(), GraphQL, a failing call, an event stream and a worker's fetch, page loads counted, request and response bodies read, never a stream's (which keeps streaming); a response override answering fetch() and XHR with its body, status and header changes (the request still reaching the server) and marking the row, its delay, only its method and GraphQL operation, a dedicated worker's fetch, and an event stream left streaming or replaced as a whole. Held and unsent requests (`held.chromium.test.ts`): an override that doesn't send answering another origin's POST and its preflight with the server never hit (and, without it, the server turning the preflight away); breakpoints holding a response (its row marked) until answered edited or as it was, a request before it is sent (its row marked) sent elsewhere or answered, one failed, one the page gave up on let go, everything let go when interception stops, and nothing stopped once off. In `network.chromium.test.ts` too: patch mode keeping the live values it wasn't told to change and answering a failing upstream with the saved text; the page and its worker taken offline and slowed down; a WebSocket's handshake, text and binary messages read incrementally, and its close; the log written as HAR with its bodies and read back as overrides that answer without the server. The page stack (`stack.chromium.test.ts`): a React app the test bundles from the repo's react and react-dom, found through the hook stand-in with its version, as a production and a development build, and by its keys with **Framework hooks** off (and no hook left in the page); what Vue, Pinia, Angular, Next.js and webpack leave in a page; a cross-site iframe's React, which found the stand-in in place before it loaded; no console rows. The component inspector (`inspect.chromium.test.ts`), with real pointer input on carts in React and Vue the test bundles (`test/helpers/bundleApp.ts`), as minified production builds with source maps and as development builds: a production React component's chain, props, state, context and handler, each function traced through the map to its source line; a click the page never gets, and the element as `$0`; the chain's other components, and an element that is gone; a production Vue component through its app's vnode tree, with what the app provides, and a development build's from the element; picking inside a cross-site iframe on its own session; Esc in the page; a useState hook set and rendered, JSON and unsettable values refused; Vue's data and a setup ref set, a computed one refused; the Components tree of a React app through the hook stand-in and by its containers without it, and of a production Vue app (each function placed once however many of its components a level lists), nodes opened as picks at their depth, picks' paths; renders recorded on a React app whose buttons each commit for another reason (own state and a store, a context with a memo child skipped, a parent's state with changed props, a class's state), with triggers, frames, durations and order, nothing after stopping, and a production build's mount recorded from a document loaded while recording, its functions placed through the map. The page stack (`stack.chromium.test.ts`) also counts a frame's scripts that name a source map by comment or header. The other frameworks (`frameworks.chromium.test.ts`), each bundled by the test from the repo's own packages: a production Angular component read through its view registry (found once, then taken from the page without walking the heap again; inputs, a settable signal, a field that isn't, each class placed by the map; hosts named by their tags while hovering), a development build through `window.ng` and its tree, a production build's tree through the registry; a Vue 2 component's props, data (set), what the app provides, chain and tree; a Lit component in its shadow root, its properties and state (set), host chain and tree; a plain page's listeners with names, places and options; a Vue 3 listener's function, not Vue's invoker; store actions (`stores.chromium.test.ts`): Redux Toolkit's (the slice's type, payload, what changed, the handler's line through the map; the page stack names Redux), the React commit after one naming it and nothing once stopped, Zustand's through its devtools, Pinia's actions and direct changes (heard again once an action that never settles stops counting) and Vuex's mutations (found when recording starts and by a document loaded while recording), NgRx's through its StoreDevtools; a request keeping the stack of the handler that sent it; saved actions (`stateActions.chromium.test.ts`): a picked component's action written, the page loaded again, and the action setting the value in React (a hook), Vue 3 production and development (a ref and data), Vue 2 (data), Angular development (a signal; production gets none) and Lit (through two shadow roots). Captures (the viewport, the whole page past it, an element in a cross-site frame, pixel-exact), the design over the page (a strict CSP, difference, after a reload, put back), a Chromium browser driven with your changes (launched, served, reloaded after a change, captured, reached again, forgotten once quit) and Firefox the same way over BiDi, with rules and a CORS preflight and a GraphQL operation named by the body sent (where Firefox is found: `FIREFOX_PATH` or on the PATH); a page 40,000 rows tall captured in parts and joined row for row, in Chromium at densities 1 and 2 and in Firefox; the design over driven tabs (at its width, after a reload, out of captures, only in a top document); your everyday Chrome, stood in for by a Chromium on an everyday profile with a debugging port (found, served only in the tab it opens, let go of as it was); a browser driven through Playwright, with Playwright's Chromium standing in for WebKit (overrides, rules, a cross-origin override and a GraphQL operation, a reload after a change, captures and one at a viewport, the design, its cookies kept once let go of) | `npm test` (skips if no Chromium; `npx playwright install chromium`) | +| End-to-end | Built Electron app driven by Playwright: on Linux, the editor's title bar in place of the system's (the window buttons clear of its controls, the bar a drag area and its controls not), open site, edit, save, page runs it, disable/enable, edit files inside a cross-site and a nested iframe, the fixture's `/workers/` page with its workers' files listed under their worker's badge and edited (imports of each kind of worker; a module worker's static import; the first script of a dedicated worker, a shared worker, a service worker and a worklet; a service worker's script edited again, turned off and on, and deleted; a worker under a cross-site iframe), the update check's toast with Bypass service workers off and its reload, what stays listed when the page leaves (its site's service worker) and when it leaves the site (nothing), each workspace running its own edit of the service worker's script (`workers.e2e.test.ts`), persistence across restart, a second launch handing over its URL, workspaces (a new one starts empty and doesn't serve another's overrides, takes its site's favicon, switching back restores the page, tabs and overrides with no history from the other, renaming, all of it across a restart), the console (each frame's rows, running code in a picked frame and seeing another react, filtering by frame, naming a frame and keeping the name across a restart, clearing), actions (`actions.e2e.test.ts`: code run in the console saved as an action for its frame, run with one click and another frame reacting, one for a frame picked from the page showing what it threw, run from the palette, changed and deleted, each workspace's own across a restart), the Actions panel in a window of its own (`actionsWindow.e2e.test.ts`: moved there, running and making actions from it, the workspace and the settings following it, Keep on top, reopened where it was after a restart, back by closing it, from the sidebar's notice and from the View menu), the website in a window of its own (`pageWindow.e2e.test.ts`: moved there from the preview's toolbar without reloading, the editor's saved edits served to it, navigating from its toolbar, back from its button, by closing it, from the title bar and from the View menu; the site's pop-ups moving with it; put back, or asked for its address bar, while its window still loads; where it was and that it was open kept across a restart). Rules: block a script from the file tree, turn it off and on, undo a quick rule, remove a page's CSP, allow CORS for an API with a preflight, keep an unapplied edit on its tab and apply it from File › Save, rules per workspace, delete, and blocking from the first load after a restart. Updates against a local feed: the automatic announcement, What's New with the release's notes, a download refused for its checksum and then accepted. The original sources behind a bundle: listed, opened read-only, jumping to the pretty-printed bundle line and back, found from the palette. The Network panel (`network.e2e.test.ts`): the page's fetch and GraphQL calls listed, Fetch/XHR first and the document under All; a response opened, edited, saved and served to the page, the row marked and the override in the Explorer with its method; another status applied from the response row; the override turned off; requests like the selected one paused and an edited response sent to the page, a paused response saved as an override that answers from then on, a breakpoint written in the menu holding a request before it is sent, and that request failed; the live response patched from the response row, a quick edit saved and served, the page taken offline and back from the network speed menu, a WebSocket's messages in its details, a response edited as a tree with the keyboard (a value retyped, a key renamed, a member removed) and saved, and the requests exported as HAR and imported back as overrides. The page stack (`stack.e2e.test.ts`): the status bar names the fixture's UI libraries and opens the Page stack, which lists each finding by kind with its version, build and evidence, and scans again. The component inspector (`inspect.e2e.test.ts`) on a production React cart framed from another site: the toolbar's button picks and the Inspect view shows what is under the pointer; a click opens the Component page, named `CartItem` and placed at its source line through the map, with props, state, context, the handler by its original name and the chain; **Open original** and **Go to bundle code** land on its definition; the View menu starts and stops picking, and Esc in the editor stops it; the Inspect view's tree lists the cart frame's components down to the one picked, selected; a state value named `qty` (from the original) is set from the Component page and the cart shows it; **Record renders** from the palette records a real click, the Renders tab says it was a click on `button#add-A1` and that `CartItem` rendered for `state qty 5 → 6`, and the Component page lists that render. A map from a file (`mapFile.e2e.test.ts`): a production React bundle that names no map, picked, given its map through **Load a source map…** (the system's dialog stubbed in the main process) and named `CartItem` at its line, then forgotten; a plain page's element shown with its listener and where it is defined; the inspected cart's renders by component, and its set value kept as an action that sets it again after a reload; a Redux Toolkit cart (`dataFlow.e2e.test.ts`): its actions recorded from the palette with what they changed and the line that dispatched them (and their stack), the commit after one naming it, and a request's **Sent by** stack traced to the handler. Other browsers on Linux (a fake launcher found, opened, turned off, added and removed), captures (the shots menu, a capture's page, an element in a cross-site frame, per workspace), designs (imported, compared: 41.9 % in two areas, laid over the page, a Figma frame brought in by its link from a stand-in for Figma's API, its token asked for once and forgotten), a real Chromium launcher opened with your changes, its tab captured and the page captured in every browser as a group, the same with Firefox where it is found, your everyday Firefox's tabs read from a session file, your own Chromium (remote debugging on) served your changes only in the tab the app opens, and WebKit downloaded from a stand-in for Playwright's host once agreed, a build that can't start said so, and its download removed in Settings (`webkit.e2e.test.ts`) | `npm run test:e2e` (on headless Linux: `xvfb-run npm run test:e2e`) | | Packaged | The installed app (asar, fuses, signature) fixes the demo store's checkout through the UI, driven over `--remote-debugging-port` since the fuses disable Node's inspector. The release workflow runs it on six runners, one per architecture: macOS (from the disk image), Windows (after a silent install; the x64 runner also checks that the ARM installer refuses it) and Linux (from the installed `.deb`, with Ubuntu's user-namespace restriction left on, also checking that the desktop entry and icons are the package's and the app added none of its own) | `npm run test:packaged -- ` | | Update | An installed app updates itself to a build one patch higher, served by a local stand-in for GitHub: the notification, What's New, the download, **Restart to update**, the restarted app running the new version (and What's New after it); an AppImage's desktop entry starts the old file before and the renamed one after. The release workflow runs it for the Windows installers (then uninstalls, checking the updater's cache goes too) and the AppImages on their four runners. The `.deb` path (as root, through `sudo`, and with the password refused) and the AppImage installing on quit were checked by hand | `npm run test:update -- ` | ## 10. Packaging and releases -`electron-builder.ts` configures electron-builder; `npm run dist` builds the current system's installers into `dist/`. electron-vite bundles everything the app runs, dependencies included, into `out/`, so the package holds only `out/` and `package.json` (about 26 MB before Electron itself), plus the icons on Linux. +`electron-builder.ts` configures electron-builder; `npm run dist` builds the current system's installers into `dist/`. electron-vite bundles everything the app runs, dependencies included, into `out/`, so the package holds only `out/`, `package.json` and `node_modules/playwright-core` (which can't be bundled: it reads its own files by the paths it computes; it drives the WebKit build the app downloads, §6.16), about 39 MB before Electron itself, plus the icons on Linux. | System | Installers | Notes | |---|---|---| @@ -804,7 +942,7 @@ The package manager is asked rather than electron-builder's `resources/package-t - Docked/undocked page view, and responsive device presets. **M3: Your own Chrome, and distribution** -- External Chrome mode: launch Chrome with a dedicated `--user-data-dir` plus `--remote-debugging-port`, or connect to a running one; `WebSocketTransport` implementing `CdpTransport`; one engine per tab. +- ✅ External Chrome mode: a Chromium browser launched with a profile of the app's own and a debugging port, one engine per tab over a browser-level WebSocket connection (§6.16, M5), and your everyday Chrome with remote debugging turned on for it (U14). - ✅ Installers with electron-builder, built and smoke-tested on all three systems by the release workflow (§10). - ✅ Update notifications, What's New, and installing updates on Windows and with the AppImage, `.deb` and `.rpm` (§10.1). - Signed and notarized builds, and with them installing updates in place on macOS. @@ -815,6 +953,17 @@ The package manager is asked rather than electron-builder's `resources/package-t - ✅ Console panel for the page and every frame in it (§6.7), and actions: code kept to run in a frame with one click (§6.9). The Actions panel also goes into a window of its own. Still to come ([research](ACTIONS_RESEARCH.md)): parameters in actions, sending a message to a frame without writing code, a message log of `postMessage` between frames, waiting for a log, scenarios, showing a frame in the page, reloading or retargeting one frame, network and storage per frame. - Component inspector ([research](INSPECTOR_RESEARCH.md)): ✅ the page stack, which UI library, framework, state library and bundler each frame runs (§6.12); ✅ the React or Vue component behind an element, its original file (production builds too, through source maps), props, state, context, handlers and chain (§6.13); ✅ the Components tree, setting state, hook names from originals and source-map coverage (§6.12, §6.13); ✅ Renders, why each React component rendered (§6.14). ✅ Angular (development and production builds), Vue 2 and web components, the listeners on an element, and **Load a source map…** for a bundle whose map isn't published (§6.8, §6.13). ✅ Store actions (Redux, Redux Toolkit, NgRx, Zustand, Pinia, Vuex) with what each changed and the line that dispatched it (§6.15); who sent a request, and a component's requests (§6.10, §6.13); a state value kept as an action (§6.13); renders by component (§6.14). Still to come: Vue's renders, a data-flow view (what comes into a component and what it sets or calls), and async stacks for requests and actions. +**M5: Other browsers, captures and designs** ([research](BROWSERS_RESEARCH.md)) +- ✅ Open the page in another installed browser: found per system, with their own icons, in a menu beside the address bar and in the palette (§6.16). +- ✅ Captures of the viewport, the whole page or one element, kept per workspace and listed in a menu at the end of the toolbar, with search and Captures/Designs (§6.17). +- ✅ Designs imported from files or the clipboard, and a compare page: side by side, swipe, onion skin and difference, with the share of pixels that differ (§6.17). +- ✅ A design laid over the live page (a canvas in an isolated world, so a strict CSP doesn't block it), at the design's width (§6.18). +- ✅ Chromium browsers with your overrides and rules (M3's external Chrome), and their tabs in the menu (§6.16). +- ✅ One capture in every browser, compared in a grid against a baseline (§6.17). +- ✅ Firefox with your changes over WebDriver BiDi (§6.16), and your everyday Firefox's tabs in the menu (on macOS, Safari's, Chrome's and others' too). +- ✅ WebKit with your changes through Playwright's build, downloaded on first use (§6.16); the design over driven tabs (§6.18); your macOS browsers' tabs; Figma frames by link, full pages taller than a texture, and anti-aliasing told apart in the difference (§6.17). +- Still to come ([research](BROWSERS_RESEARCH.md)): the console, Network panel and inspector for an outside tab; WebKit itself checked on a machine that can download its build. + ## 12. Risks and open questions | Risk | Mitigation | diff --git a/electron-builder.ts b/electron-builder.ts index 785146df..ee9bb149 100644 --- a/electron-builder.ts +++ b/electron-builder.ts @@ -16,8 +16,9 @@ const config: Configuration = { productName: 'Console Editor', copyright: 'Copyright © 2026 olehwebdev', directories: { output: 'dist', buildResources: 'build' }, - // electron-vite bundles everything the app runs (dependencies included) into out/. - files: ['out/**/*', '!node_modules/**/*'], + // electron-vite bundles everything the app runs (dependencies included) into out/, but playwright-core, which drives + // the WebKit build the app downloads: it reads its own files by the paths it computes, so it ships as it is. + files: ['out/**/*', '!node_modules/**/*', 'node_modules/playwright-core/**/*'], // The icon is also given to GTK (Linux About dialog), which can't read inside app.asar. asarUnpack: ['out/main/chunks/*.png'], npmRebuild: false, @@ -59,6 +60,8 @@ const config: Configuration = { NSLocationUsageDescription: 'A website open in Console Editor wants to know your location.', NSLocationWhenInUseUsageDescription: 'A website open in Console Editor wants to know your location.', NSLocalNetworkUsageDescription: 'Console Editor opens websites on your local network when you ask it to.', + // Asked when you list your other browsers' tabs; without it macOS refuses without asking. + NSAppleEventsUsageDescription: 'Console Editor lists the tabs open in your browsers when you ask it to, to open one here.', }, }, dmg: { writeUpdateInfo: false }, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 94b7d223..93ecf4f7 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -4,8 +4,9 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'electron-vite'; export default defineConfig({ - // Bundle dependencies too, so out/ is all the installers need (see electron-builder.ts). - main: { build: { externalizeDeps: false } }, + // Bundle dependencies too, so out/ is almost all the installers need (see electron-builder.ts). playwright-core can't + // be: it reads its own files by the paths it computes. It is loaded from node_modules when WebKit is first used. + main: { build: { externalizeDeps: false, rolldownOptions: { external: [/^playwright-core(\/|$)/] } } }, preload: { build: { externalizeDeps: false } }, renderer: { resolve: { diff --git a/package-lock.json b/package-lock.json index 4b7e1c31..98e8f291 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "marked": "^18.0.14", "monaco-editor": "^0.56.0", "motion": "^13.4.2", + "playwright-core": "1.63.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.88.0", @@ -59,7 +60,6 @@ "oxlint": "^1.85.0", "oxlint-tsgolint": "^7.0.2003", "pinia": "^4.0.3", - "playwright-core": "^1.63.0", "react-redux": "^9.3.0", "rxjs": "^7.8.2", "secretlint": "^13.0.6", @@ -9265,7 +9265,6 @@ "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", - "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index d5b0fce7..5cdd049d 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "oxlint": "^1.85.0", "oxlint-tsgolint": "^7.0.2003", "pinia": "^4.0.3", - "playwright-core": "^1.63.0", "react-redux": "^9.3.0", "rxjs": "^7.8.2", "secretlint": "^13.0.6", @@ -106,6 +105,7 @@ "marked": "^18.0.14", "monaco-editor": "^0.56.0", "motion": "^13.4.2", + "playwright-core": "1.63.0", "react": "^19.3.0", "react-dom": "^19.3.0", "react-hook-form": "^7.88.0", diff --git a/src/main/ActionsWindow/ActionsWindow.ts b/src/main/ActionsWindow/ActionsWindow.ts index a7f0c3f7..62472d7a 100644 --- a/src/main/ActionsWindow/ActionsWindow.ts +++ b/src/main/ActionsWindow/ActionsWindow.ts @@ -1,9 +1,8 @@ import { screen, type BrowserWindow, type WebContents } from 'electron'; import { ACTIONS_WINDOW_HASH } from '../../shared/constants'; -import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { ActionsWindowState, AppEvent } from '../../shared/types'; import { loadEditor } from '../launch/loadEditor'; -import { createAppWindow, DockOnClose, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; +import { createAppWindow, DockOnClose, forwardEvent, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; import { ACTIONS_WINDOW_MENU_ID, ACTIONS_WINDOW_SIZE, ACTIONS_WINDOW_TITLE, FORWARDED_EVENTS, MIN_ACTIONS_WINDOW_SIZE } from './constants'; import type { ActionsWindowDeps } from './types'; @@ -76,8 +75,7 @@ export class ActionsWindow { /** Hands the window's UI an event its panel shows; the editor's other events aren't its business. */ forward(event: AppEvent): void { - const win = this.win; - if (win && !win.isDestroyed() && FORWARDED_EVENTS.has(event.type)) win.webContents.send(IPC_CHANNEL.onEvent, event); + forwardEvent(this.win, FORWARDED_EVENTS, event); } /** The editor's window is gone (the app is quitting): this window goes too, and opens again next time. */ diff --git a/src/main/PageController/PageController.ts b/src/main/PageController/PageController.ts index 5e66e15a..1b230e4c 100644 --- a/src/main/PageController/PageController.ts +++ b/src/main/PageController/PageController.ts @@ -1,5 +1,6 @@ import type { BrowserWindow, Session, WebContentsView } from 'electron'; import type { PageState } from '../../shared/types'; +import type { CdpTransport } from '../engine/cdp'; import type { PageInterception } from '../engine/PageInterception'; import type { NetworkLog } from '../network'; import { PageWindow } from '../PageWindow'; @@ -29,6 +30,8 @@ export class PageController { /** The requests the page, its frames and their workers send. */ readonly network: NetworkLog; private readonly engine: PageInterception; + /** The page's own CDP session (captures go through it). */ + readonly cdp: CdpTransport; /** The site's session (cookies, logins): reads out of the page go through it, like its favicon and source maps. */ readonly siteSession: Session; private readonly loader: PageLoader; @@ -41,13 +44,13 @@ export class PageController { this.window = new PageWindow({ editor: win, view: this.view, store: windowStore, moved: () => this.pushState() }); const wc = this.view.webContents; - const transport = attachDebugger(wc, (reason) => { + this.cdp = attachDebugger(wc, (reason) => { // Settle everything the engine is waiting on; nothing can be sent any more. this.engine.detach(); this.deps.send({ type: 'error', message: `Interception stopped: debugger detached (${reason})` }); }); - ({ frames: this.frames, network: this.network, engine: this.engine } = wirePage(transport, { store, rules, settings, send, siteSession: this.siteSession, breakpoints })); + ({ frames: this.frames, network: this.network, engine: this.engine } = wirePage(this.cdp, { store, rules, settings, send, siteSession: this.siteSession, breakpoints })); this.loader = new PageLoader(wc, this.engine, () => this.pushState()); // A page's "Leave site?" guard would silently cancel reloads after a save, diff --git a/src/main/PageWindow/PageWindow.ts b/src/main/PageWindow/PageWindow.ts index b6f83a9b..82087981 100644 --- a/src/main/PageWindow/PageWindow.ts +++ b/src/main/PageWindow/PageWindow.ts @@ -4,8 +4,8 @@ import { IPC_CHANNEL } from '../../shared/ipcChannels'; import type { AppEvent, PageState, Rect } from '../../shared/types'; import { loadEditor } from '../launch/loadEditor'; import { AddressBarFocus } from './AddressBarFocus'; -import { createAppWindow, DockOnClose, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; -import { DEFAULT_WINDOW_SIZE, MIN_WINDOW_SIZE, PAGE_WINDOW_MENU_ID, UNTITLED } from './constants'; +import { createAppWindow, DockOnClose, forwardEvent, placeWindow, setUpWindow, syncMenuCheck, trackPlacement } from '../windows'; +import { DEFAULT_WINDOW_SIZE, FORWARDED_EVENTS, MIN_WINDOW_SIZE, PAGE_WINDOW_MENU_ID, UNTITLED } from './constants'; import { toViewBounds } from './toViewBounds'; import type { PageWindowDeps } from './types'; @@ -84,6 +84,11 @@ export class PageWindow { this.focus?.request(); } + /** Sends the website window's UI an event its toolbar's menus show, if it has a window and hears that event. */ + forward(event: AppEvent): void { + forwardEvent(this.win, FORWARDED_EVENTS, event); + } + /** `sender` asked for the page's state: the website window's UI does that once it listens for events. */ listening(sender: WebContents): void { if (this.owns(sender)) this.focus?.uiReady(); diff --git a/src/main/PageWindow/constants.ts b/src/main/PageWindow/constants.ts index 0404612b..4aae1df7 100644 --- a/src/main/PageWindow/constants.ts +++ b/src/main/PageWindow/constants.ts @@ -1,3 +1,6 @@ +import { PAGE_WINDOW_EVENTS } from '../../shared/constants'; +import type { AppEvent } from '../../shared/types'; + /** A new website window's size, before it is fitted to its screen. */ export const DEFAULT_WINDOW_SIZE = { width: 1280, height: 900 }; @@ -9,3 +12,6 @@ export const UNTITLED = 'Website'; /** The View menu's item for the website window (its check mark follows where the website is). */ export const PAGE_WINDOW_MENU_ID = 'page-window'; + +/** The events the website window's UI hears besides its page's state and its shortcut (which go out on their own). */ +export const FORWARDED_EVENTS: ReadonlySet = new Set(PAGE_WINDOW_EVENTS); diff --git a/src/main/WorkspaceController/WorkspaceController.ts b/src/main/WorkspaceController/WorkspaceController.ts index c1afdc2e..889ceb2a 100644 --- a/src/main/WorkspaceController/WorkspaceController.ts +++ b/src/main/WorkspaceController/WorkspaceController.ts @@ -1,6 +1,7 @@ import type { WebContents } from 'electron'; import type { AppEvent, Workspace, WorkspacePatch } from '../../shared/types'; import type { PageController } from '../PageController'; +import type { PageShots } from '../shots'; import type { ActionStore } from '../store/ActionStore'; import type { OverrideStore } from '../store/OverrideStore'; import type { RuleStore } from '../store/RuleStore'; @@ -10,7 +11,7 @@ import { PageFollower } from './PageFollower'; /** * Workspaces: each has its own page, tabs (kept by the renderer through the - * session store), overrides, rules, actions and source maps loaded from files. + * session store), overrides, rules, actions, source maps loaded from files, and captures and designs. * Switching leaves the page, makes the other workspace's overrides and rules the * ones applied and its actions the ones listed, and loads its last page. */ @@ -27,6 +28,7 @@ export class WorkspaceController { private readonly actions: ActionStore, private readonly send: (event: AppEvent) => void, private readonly sourceMaps?: SourceMapFileStore, + private readonly shots?: PageShots, ) { this.follower = new PageFollower(page, session, send); } @@ -45,6 +47,7 @@ export class WorkspaceController { this.rules.setWorkspace(activeId); this.actions.setWorkspace(activeId); this.sourceMaps?.setWorkspace(activeId); + this.shots?.setWorkspace(activeId); } /** Follows the page: remembers where the active workspace is, and its site's icon. */ @@ -87,6 +90,7 @@ export class WorkspaceController { // start would hand its overrides and rules to another workspace. await this.actions.removeWorkspace(id as string); await this.sourceMaps?.removeWorkspace(id as string); + await this.shots?.removeWorkspace(id as string); await this.store.removeWorkspace(id as string); await this.rules.removeWorkspace(id as string); await this.session.remove(id); @@ -108,10 +112,12 @@ export class WorkspaceController { this.rules.setWorkspace(this.session.activeId); this.actions.setWorkspace(this.session.activeId); this.sourceMaps?.setWorkspace(this.session.activeId); + this.shots?.setWorkspace(this.session.activeId); // One pattern refresh reads both stores; the rules then only need their event. await this.page.overridesChanged(); await this.page.rulesChanged(false); this.send({ type: 'actions-changed', actions: this.actions.list() }); + this.shots?.changed(); this.pushState(); const { url } = this.session.get(); if (url) void this.page.navigate(url, { fresh: true }); diff --git a/src/main/browsers/BrowserRegistry/BrowserRegistry.ts b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts new file mode 100644 index 00000000..154ade5a --- /dev/null +++ b/src/main/browsers/BrowserRegistry/BrowserRegistry.ts @@ -0,0 +1,149 @@ +import { randomBytes } from 'node:crypto'; +import { homedir } from 'node:os'; +import { basename, extname } from 'node:path'; +import type { BrowserInfo } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import { browserIcon } from '../browserIcon'; +import { BROWSER_ID_PREFIX, SCAN_REUSE_MS } from '../constants'; +import { engineOf } from '../engineOf'; +import { findBrowsers } from '../findBrowsers'; +import { readVersion } from '../readVersion'; +import { reachableEveryday } from '../driven/everyday/reachableEveryday'; +import { startBrowser } from '../startBrowser'; +import type { BrowserRegistryDeps, FoundBrowser } from '../types'; +import { addedBrowser } from './addedBrowser'; +import { BuiltBrowsers } from './BuiltBrowsers'; +import { checkBrowserPath } from './checkBrowserPath'; +import { infoOf } from './infoOf'; + +/** Bytes of an added browser's id. */ +const ID_BYTES = 4; + +/** + * The browsers on this computer and the ones the user added: looked for at most once a minute, with their icons; + * their versions are read one at a time afterwards and announced (`browsers-changed`) once all are in. + */ +export class BrowserRegistry { + private found: FoundBrowser[] = []; + private scannedAt = Number.NEGATIVE_INFINITY; + private scanning: Promise | undefined; + private readingVersions = false; + private readonly icons = new Map(); + private readonly versions = new Map(); + /** The browsers whose everyday profile ran with remote debugging on when they were last listed. */ + private debuggable = new Set(); + private readonly builds: BuiltBrowsers; + + constructor(private readonly deps: BrowserRegistryDeps) { + this.builds = new BuiltBrowsers(deps.builds); + } + + async list(): Promise { + await this.scan(); + const home = this.deps.home ?? homedir(); + const reachable = await Promise.all(this.all().map(async (b) => ((await reachableEveryday(b, home)) ? [b.id] : []))); + this.debuggable = new Set(reachable.flat()); + return this.infos(); + } + + /** The browsers found and added, looked for again first when the last scan is old. */ + async installed(): Promise { + await this.scan(); + return this.all(); + } + + /** A browser by id, looked for again first when the last scan is old. */ + async get(id: string): Promise { + await this.scan(); + const browser = this.all().find((b) => b.id === id); + if (!browser) throw new Error('That browser is no longer there'); + return browser; + } + + /** Opens an http(s) address in a browser, with its everyday profile (a build the app downloads opens with changes only). */ + async open(id: string, url: string): Promise { + if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); + const browser = await this.get(id); + if (this.builds.has(id)) throw new Error(`${browser.name} opens with your changes only`); + await startBrowser(browser, [url]); + } + + /** A build the app downloads was downloaded or removed. */ + async buildsChanged(): Promise { + await this.builds.refresh(); + this.changed(); + } + + /** Adds the program (or macOS app) at `path`, named after its file. */ + async add(path: string): Promise { + await checkBrowserPath(path); + const name = basename(path, extname(path)); + const added = { id: `${BROWSER_ID_PREFIX.added}${randomBytes(ID_BYTES).toString('hex')}`, name, path, engine: engineOf([path, name]) }; + await this.deps.prefs.add(added); + const browser = addedBrowser(added); + this.icons.set(browser.id, await browserIcon(browser)); + this.changed(); + void this.readVersions(); + return infoOf(browser, this.icons.get(browser.id), null, new Set(), new Set(), null); + } + + async remove(id: string): Promise { + if (!this.deps.prefs.get().added.some((b) => b.id === id)) throw new Error('Only a browser you added can be removed'); + await this.deps.prefs.remove(id); + this.changed(); + } + + async setHidden(id: string, hidden: boolean): Promise { + await this.deps.prefs.setHidden(id, hidden); + this.changed(); + } + + private all(): FoundBrowser[] { + return [...this.found, ...this.builds.browsers(), ...this.deps.prefs.get().added.map(addedBrowser)]; + } + + private infos(): BrowserInfo[] { + const hidden = new Set(this.deps.prefs.get().hidden); + return this.all().map((b) => infoOf(b, this.icons.get(b.id), this.builds.version(b.id) ?? this.versions.get(b.id), hidden, this.debuggable, this.builds.state(b.id))); + } + + private changed(): void { + this.deps.send({ type: 'browsers-changed', browsers: this.infos() }); + } + + private async scan(): Promise { + if (Date.now() - this.scannedAt < SCAN_REUSE_MS) return; + this.scanning ??= (async () => { + const found = (await (this.deps.find ?? findBrowsers)().catch((): FoundBrowser[] => [])).sort((a, b) => a.name.localeCompare(b.name)); + await this.builds.refresh(); + const all = [...found, ...this.builds.browsers(), ...this.deps.prefs.get().added.map(addedBrowser)]; + await Promise.all(all.filter((b) => !this.icons.has(b.id)).map(async (b) => this.icons.set(b.id, await browserIcon(b)))); + this.found = found; + this.scannedAt = Date.now(); + })().finally(() => { + this.scanning = undefined; + }); + await this.scanning; + void this.readVersions(); + } + + /** One at a time (a browser's program can be slow to start); announced once, when any was read. */ + private async readVersions(): Promise { + if (this.readingVersions) return; + this.readingVersions = true; + let read = false; + try { + for (let next = this.unversioned(); next; next = this.unversioned()) { + this.versions.set(next.id, await readVersion(next)); + read = true; + } + } finally { + this.readingVersions = false; + } + if (read) this.changed(); + } + + private unversioned(): FoundBrowser | undefined { + return this.all().find((b) => !this.versions.has(b.id) && !this.builds.has(b.id)); + } +} diff --git a/src/main/browsers/BrowserRegistry/BuiltBrowsers.ts b/src/main/browsers/BrowserRegistry/BuiltBrowsers.ts new file mode 100644 index 00000000..c9267876 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/BuiltBrowsers.ts @@ -0,0 +1,32 @@ +import type { FoundBrowser } from '../types'; +import type { BuiltBrowser } from '../webkit'; + +/** The browser builds the app downloads (WebKit's), as the registry lists them: as browsers, with their versions, downloaded or not. */ +export class BuiltBrowsers { + private built: BuiltBrowser[] = []; + + constructor(private readonly source?: { list(): Promise }) {} + + /** Asks again which there are, and which are downloaded. */ + async refresh(): Promise { + this.built = (await this.source?.list().catch((): BuiltBrowser[] => [])) ?? []; + } + + browsers(): FoundBrowser[] { + return this.built.map((b) => b.browser); + } + + has(id: string): boolean { + return this.built.some((b) => b.browser.id === id); + } + + version(id: string): string | null | undefined { + return this.built.find((b) => b.browser.id === id)?.version; + } + + /** What the UI is told of a build: whether it is downloaded; null for a browser that isn't one. */ + state(id: string): { downloaded: boolean } | null { + const built = this.built.find((b) => b.browser.id === id); + return built ? { downloaded: built.downloaded } : null; + } +} diff --git a/src/main/browsers/BrowserRegistry/addedBrowser.ts b/src/main/browsers/BrowserRegistry/addedBrowser.ts new file mode 100644 index 00000000..954c4083 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/addedBrowser.ts @@ -0,0 +1,10 @@ +import { OPEN_APP_FLAG, OPEN_COMMAND, APP_EXTENSION } from '../findBrowsers/mac/constants'; +import type { AddedBrowser } from '../../store/BrowserStore'; +import type { FoundBrowser } from '../types'; + +/** A browser the user added, as one found: its program started with the address (a macOS app through `open -a`). */ +export function addedBrowser({ id, name, path, engine }: AddedBrowser): FoundBrowser { + const macApp = process.platform === 'darwin' && path.endsWith(APP_EXTENSION); + const command = macApp ? [OPEN_COMMAND, OPEN_APP_FLAG, path] : [path]; + return { id, name, engine, command, urlAt: command.length, iconFile: null, app: path, program: macApp ? null : path, added: true }; +} diff --git a/src/main/browsers/BrowserRegistry/checkBrowserPath.ts b/src/main/browsers/BrowserRegistry/checkBrowserPath.ts new file mode 100644 index 00000000..aac8a878 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/checkBrowserPath.ts @@ -0,0 +1,14 @@ +import { constants } from 'node:fs'; +import { access, stat } from 'node:fs/promises'; +import { APP_EXTENSION } from '../findBrowsers/mac/constants'; + +/** Rejects a path that isn't a program this system can start: an app bundle on macOS, else an executable file. */ +export async function checkBrowserPath(path: string): Promise { + const info = await stat(path).catch(() => null); + if (!info) throw new Error(`${path} isn't there`); + if (process.platform === 'darwin' && path.endsWith(APP_EXTENSION) && info.isDirectory()) return; + if (!info.isFile()) throw new Error(`${path} isn't a program`); + await access(path, constants.X_OK).catch(() => { + throw new Error(`${path} can't be run`); + }); +} diff --git a/src/main/browsers/BrowserRegistry/index.ts b/src/main/browsers/BrowserRegistry/index.ts new file mode 100644 index 00000000..7dfa50fe --- /dev/null +++ b/src/main/browsers/BrowserRegistry/index.ts @@ -0,0 +1 @@ +export { BrowserRegistry } from './BrowserRegistry'; diff --git a/src/main/browsers/BrowserRegistry/infoOf.ts b/src/main/browsers/BrowserRegistry/infoOf.ts new file mode 100644 index 00000000..d464d604 --- /dev/null +++ b/src/main/browsers/BrowserRegistry/infoOf.ts @@ -0,0 +1,9 @@ +import type { BrowserInfo } from '../../../shared/types'; +import { listsTabs } from '../everyday'; +import type { FoundBrowser } from '../types'; + +/** What the UI is told of a browser: never its command or paths. */ +export function infoOf(browser: FoundBrowser, icon: string | null | undefined, version: string | null | undefined, hidden: ReadonlySet, debuggable: ReadonlySet, build: BrowserInfo['build']): BrowserInfo { + const { id, name, engine, added } = browser; + return { id, name, engine, version: version ?? null, icon: icon ?? null, added, hidden: hidden.has(id), debuggable: debuggable.has(id), listsTabs: listsTabs(browser), build }; +} diff --git a/src/main/browsers/browserIcon/browserIcon.ts b/src/main/browsers/browserIcon/browserIcon.ts new file mode 100644 index 00000000..cc208c28 --- /dev/null +++ b/src/main/browsers/browserIcon/browserIcon.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { app, nativeImage } from 'electron'; +import { ICON_SIZE, MAX_SVG_ICON_BYTES } from '../constants'; +import type { FoundBrowser } from '../types'; + +const SVG = '.svg'; +const SVG_DATA_URL = 'data:image/svg+xml;base64,'; + +/** + * A browser's icon as a data URL: its icon file (a PNG scaled to `ICON_SIZE`, an SVG as it is), else what the system + * shows for its app or program (macOS, Windows); null when there is none. + */ +export async function browserIcon(browser: FoundBrowser): Promise { + try { + if (browser.iconFile) { + const bytes = await readFile(browser.iconFile); + if (extname(browser.iconFile) === SVG) return bytes.length <= MAX_SVG_ICON_BYTES ? `${SVG_DATA_URL}${bytes.toString('base64')}` : null; + const image = nativeImage.createFromBuffer(bytes); + return image.isEmpty() ? null : image.resize({ width: ICON_SIZE, height: ICON_SIZE, quality: 'best' }).toDataURL(); + } + // Linux's answer is the icon of the file's type (a generic program), which says nothing: the menu draws its own. + if (!browser.app || process.platform === 'linux') return null; + const image = await app.getFileIcon(browser.app, { size: 'large' }); + return image.isEmpty() ? null : image.toDataURL(); + } catch { + return null; + } +} diff --git a/src/main/browsers/browserIcon/index.ts b/src/main/browsers/browserIcon/index.ts new file mode 100644 index 00000000..85ebafb8 --- /dev/null +++ b/src/main/browsers/browserIcon/index.ts @@ -0,0 +1 @@ +export { browserIcon } from './browserIcon'; diff --git a/src/main/browsers/constants.ts b/src/main/browsers/constants.ts new file mode 100644 index 00000000..5c8ff7d6 --- /dev/null +++ b/src/main/browsers/constants.ts @@ -0,0 +1,38 @@ +import type { BrowserEngine } from '../../shared/types'; + +/** + * How a browser's engine is told from its names (launcher id, program, app or registry name), first match first: + * what the app can do with it depends on the engine. Names no pattern knows are `unknown` (open only). + */ +export const ENGINE_PATTERNS: readonly (readonly [RegExp, BrowserEngine])[] = [ + [/firefox|librewolf|waterfox|floorp|icecat|mullvad|tor[- ]?browser|seamonkey|palemoon|\bzen\b|zen[-_.]browser/i, 'gecko'], + [/safari|epiphany|gnome[- ]web|\borion\b/i, 'webkit'], + [/chrom(e|ium)|msedge|microsoft[- ]edge|brave|vivaldi|\bopera\b|^arc$|thorium|yandex|ungoogled/i, 'chromium'], +]; + +/** How long a scan for installed browsers is reused before the next one looks again. */ +export const SCAN_REUSE_MS = 60_000; + +/** The icon's longest side, in pixels: sharp at 32 px on a 2× screen. */ +export const ICON_SIZE = 64; + +/** An SVG icon larger than this is left out (they are usually a few kilobytes). */ +export const MAX_SVG_ICON_BYTES = 256 * 1024; + +/** How long asking a browser for its version may take before it is given up on. */ +export const VERSION_TIMEOUT_MS = 5000; + +/** A version number in a program's answer (`Mozilla Firefox 131.0.3`). */ +export const VERSION_NUMBER = /\d+(?:\.\d+)+/; + +/** The flag Chromium and Firefox answer with their version, without opening a window. */ +export const VERSION_FLAG = '--version'; + +/** Ids of browsers the user added, and of the ones found per system. */ +export const BROWSER_ID_PREFIX = { added: 'added:', desktop: 'desktop:', mac: 'mac:', windows: 'win:' } as const; + +/** + * Variables an AppImage's launcher sets for the app's own libraries: a browser started with them loads the wrong + * ones, so they are dropped when the app runs as an AppImage. + */ +export const APPIMAGE_ENV = { marker: 'APPIMAGE', dropped: ['LD_LIBRARY_PATH', 'LD_PRELOAD', 'GDK_PIXBUF_MODULE_FILE', 'GIO_EXTRA_MODULES', 'GSETTINGS_SCHEMA_DIR', 'GTK_PATH', 'QT_PLUGIN_PATH', 'PYTHONHOME', 'PYTHONPATH', 'PERLLIB'] } as const; diff --git a/src/main/browsers/driven/DrivenBase.ts b/src/main/browsers/driven/DrivenBase.ts new file mode 100644 index 00000000..df9c21b9 --- /dev/null +++ b/src/main/browsers/driven/DrivenBase.ts @@ -0,0 +1,75 @@ +import type { CaptureArea, DrivenBrowser } from '../../../shared/types'; +import type { PageDesign } from '../../overlay'; +import type { CapturedImage, Viewport } from '../../shots/capture'; +import type { FoundBrowser } from '../types'; +import { DrivenTabs } from './DrivenTabs'; +import type { DriverDeps, KeptTab, TabCapture, TabDesigns, TabRead } from './types'; + +/** + * What every driven browser does the same way, whatever protocol it speaks: its tabs, listed, read again and captured + * (with the design off them while they are). + */ +export abstract class DrivenBase { + protected readonly tabs = new DrivenTabs(); + protected readonly disposers: Array<() => void> = []; + + constructor( + readonly browser: FoundBrowser, + private readonly version: string | null, + protected readonly deps: DriverDeps, + ) {} + + list(): DrivenBrowser { + return this.tabs.described(this.deps.listedAs, this.browser, this.version); + } + + /** + * Reads each tab's title and address again: a title a page sets isn't announced (only its address is), so it is + * read once the page has loaded, and whenever the tabs are listed. + */ + async readTabs(ids = this.tabs.list().map((t) => t.id)): Promise { + if (this.tabs.updateAll(await this.read(ids))) this.deps.changed(); + } + + /** Captures a tab (brought to the front first: a hidden tab isn't drawn); with the address it showed. */ + async capture(tabId: string, area: Exclude): Promise { + const tab = this.tabs.get(tabId); + await this.activate(tabId); + return { image: await this.designs.hidden(tab, () => this.take(tab, area)), url: tab.info.url }; + } + + /** Captures the whole page at `url`, in the tab showing it (or one opened there, loaded), laid out in `viewport`. */ + async captureAt(url: string, viewport: Viewport): Promise { + const shown = this.tabs.showing(url); + const tab = shown ?? (await this.openLoaded(url)); + if (shown) await this.activate(tab.info.id); + return { image: await this.designs.hidden(tab, () => this.takeAt(tab, viewport)), url: tab.info.url }; + } + + setDesign(design: PageDesign | null): Promise { + return this.designs.set(design, this.tabs.all()); + } + + /** How the design is laid over this browser's tabs. */ + protected abstract readonly designs: TabDesigns; + + /** Brings a tab to the front, in its window. */ + abstract activate(tabId: string): Promise; + + /** Opens an address as `open` does, resolving once it has loaded (or at least is on its way). */ + protected abstract openLoaded(url: string): Promise; + + /** Captures a tab's whole page, in front, laid out in `viewport`, once it has loaded and been quiet a moment. */ + protected abstract takeAt(tab: T, viewport: Viewport): Promise; + + /** What the browser says of each tab now. */ + protected abstract read(ids: string[]): Promise; + + /** Captures a tab that is in front. */ + protected abstract take(tab: T, area: Exclude): Promise; + + /** Lets go of what was listened to. */ + protected dispose(): void { + for (const dispose of this.disposers.splice(0)) dispose(); + } +} diff --git a/src/main/browsers/driven/DrivenBrowsers.ts b/src/main/browsers/driven/DrivenBrowsers.ts new file mode 100644 index 00000000..5d384235 --- /dev/null +++ b/src/main/browsers/driven/DrivenBrowsers.ts @@ -0,0 +1,125 @@ +import { homedir } from 'node:os'; +import type { AppEvent, CaptureArea, DrivenBrowser, ShotBrowser } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import type { PageDesign } from '../../overlay'; +import type { BrowserCapture, CapturedImage, Viewport } from '../../shots/capture'; +import { PAGE_AREAS } from '../../shots/constants'; +import type { FoundBrowser } from '../types'; +import { captureEvery } from './captureEvery'; +import { connectorOf } from './connectorOf'; +import { RELOAD_DEBOUNCE_MS } from './constants'; +import { DriverPool } from './DriverPool'; +import { EVERYDAY_KEY_SUFFIX, EVERYDAY_NAME_SUFFIX } from './everyday/constants'; +import { shotBrowser } from './shotBrowser'; +import type { ConnectDriver, DrivenBrowsersDeps, Driver } from './types'; + +/** What each app event means for the driven browsers; the rest mean nothing to them. */ +type AppEventReactions = Partial void>>; + +/** + * The browsers the app drives (Chromium ones and Firefox, each with a profile of the app's own; the WebKit build it + * downloads; your everyday Chrome when remote debugging is on for it): launched or reached when an address is first + * opened in one with the workspace's changes, and kept in step with them. Every change is announced as + * `driven-browsers-changed`. + */ +export class DrivenBrowsers { + private readonly pool = new DriverPool(); + private reloadTimer: ReturnType | undefined; + /** The design over the app's page, if any: driven tabs have it too. */ + private design: PageDesign | null = null; + private readonly reactions: AppEventReactions = { + 'overrides-changed': () => this.served(), + 'rules-changed': () => this.served(), + 'settings-changed': () => void Promise.all(this.pool.all().map((d) => d.applySettings())), + }; + + constructor(private readonly deps: DrivenBrowsersDeps) {} + + list(): DrivenBrowser[] { + return this.pool.all().map((d) => d.list()); + } + + /** The driven browsers with their tabs' titles and addresses as they are now. */ + async read(): Promise { + await Promise.all(this.pool.all().map((d) => d.readTabs())); + return this.list(); + } + + /** + * Opens an http(s) address in a browser with the workspace's changes: with a profile of the app's own (launched if + * need be), or in your everyday Chromium browser (`everyday`), remote debugging turned on for it. + */ + async open(id: string, url: string, everyday = false): Promise { + if (!HTTP_URL.test(url)) throw new Error('Only http(s) pages open in another browser'); + const browser = await this.deps.registry.get(id); + const connect = connectorOf(browser, everyday); + if (!connect) throw new Error(`${browser.name} can't be served your changes: only Chromium browsers${everyday ? '' : ', Firefox and WebKit (the app downloads it)'} can`); + const listedAs = everyday ? { id: `${id}${EVERYDAY_KEY_SUFFIX}`, name: `${browser.name}${EVERYDAY_NAME_SUFFIX}`, everyday } : { id, name: browser.name, everyday }; + const driver = await this.pool.reach(listedAs.id, () => this.connect(browser, listedAs, connect)); + this.changed(); + await driver.open(url); + } + + activate(key: string, tabId: string): Promise { + return this.pool.get(key).activate(tabId); + } + + /** Captures a tab of a driven browser, with the address it showed and the browser it was taken in. */ + async capture(key: string, tabId: string, area: unknown): Promise<{ image: CapturedImage; url: string; browser: ShotBrowser }> { + if (!PAGE_AREAS.has(area)) throw new Error('Invalid capture area'); + const driven = this.pool.get(key); + return { ...(await driven.capture(tabId, area as Exclude)), browser: shotBrowser(driven) }; + } + + /** Captures the whole page at `url` in every driven browser at once, laid out in `viewport`; one that fails says why. */ + captureAt(url: string, viewport: Viewport): Promise { + return captureEvery(this.pool.all(), url, viewport); + } + + /** Stops serving the workspace's changes in a browser; it stays open. */ + stop(key: string): void { + this.pool.get(key).stop(); + this.pool.remove(key); + this.changed(); + } + + /** Lays the design over the app's page over every driven tab too (null: takes it off), and over tabs opened later. */ + async setDesign(design: PageDesign | null): Promise { + this.design = design; + await Promise.all(this.pool.all().map((d) => d.setDesign(design).catch(() => undefined))); + } + + /** Told every app event: overrides, rules and settings that change are served in the driven tabs too. */ + onAppEvent(event: AppEvent): void { + this.reactions[event.type]?.(); + } + + /** Lets go of every driven browser (the app is quitting); they stay open. */ + dispose(): void { + clearTimeout(this.reloadTimer); + for (const driven of this.pool.clear()) driven.stop(); + } + + private async connect(browser: FoundBrowser, listedAs: { id: string; name: string; everyday: boolean }, connect: ConnectDriver): Promise { + const closed = () => { + this.pool.remove(listedAs.id); + this.changed(); + }; + const { sources, userData } = this.deps; + const driven = await connect(browser, { sources, userData, home: this.deps.home ?? homedir(), listedAs, changed: () => this.changed(), closed }); + await driven.setDesign(this.design).catch(() => undefined); + return driven; + } + + /** Overrides or rules changed: each tab intercepts what they now need, and reloads (after a burst, once) if the settings say so. */ + private served(): void { + void Promise.all(this.pool.all().map((d) => d.refresh())); + if (!this.deps.sources.settings.get().autoReloadOnSave || this.pool.size === 0) return; + clearTimeout(this.reloadTimer); + this.reloadTimer = setTimeout(() => void Promise.all(this.pool.all().map((d) => d.reload())), RELOAD_DEBOUNCE_MS); + } + + private changed(): void { + this.deps.send({ type: 'driven-browsers-changed', driven: this.list() }); + } +} diff --git a/src/main/browsers/driven/DrivenTabs.ts b/src/main/browsers/driven/DrivenTabs.ts new file mode 100644 index 00000000..2ebfce4b --- /dev/null +++ b/src/main/browsers/driven/DrivenTabs.ts @@ -0,0 +1,90 @@ +import type { DrivenBrowser, DrivenTab } from '../../../shared/types'; +import { HTTP_URL } from '../../constants'; +import type { FoundBrowser } from '../types'; +import { START_URL } from './constants'; +import type { DriverDeps, KeptTab, TabRead } from './types'; + +/** A driven browser's tabs, in the order they were found; waits for the tab a new one becomes. */ +export class DrivenTabs { + private readonly tabs = new Map(); + private readonly arrivals = new Map void>(); + + add(tab: T): void { + this.tabs.set(tab.info.id, tab); + this.arrivals.get(tab.info.id)?.(tab); + this.arrivals.delete(tab.info.id); + } + + has(id: string): boolean { + return this.tabs.has(id); + } + + /** Forgets a tab (it closed), and returns it. */ + remove(id: string): T | undefined { + const tab = this.tabs.get(id); + this.tabs.delete(id); + return tab; + } + + /** Takes a tab's new address or title; false when it isn't a tab here, or neither changed. */ + update(id: string, { url, title }: Partial>): boolean { + const tab = this.tabs.get(id); + const next = tab && { ...tab.info, ...(url === undefined ? {} : { url }), ...(title === undefined ? {} : { title }) }; + if (!tab || !next || (next.url === tab.info.url && next.title === tab.info.title)) return false; + tab.info = next; + return true; + } + + /** Takes what was read of each tab again; whether anything changed. */ + updateAll(reads: readonly TabRead[]): boolean { + return reads.filter(({ id, ...change }) => this.update(id, change)).length > 0; + } + + get(id: string): T { + const tab = this.tabs.get(id); + if (!tab) throw new Error('That tab is closed'); + return tab; + } + + /** The tab of a target just created, once it is found. */ + arrival(id: string): Promise { + const tab = this.tabs.get(id); + return tab ? Promise.resolve(tab) : new Promise((resolve) => this.arrivals.set(id, resolve)); + } + + /** The tab showing an address, if one does. */ + showing(url: string): T | undefined { + return this.all().find((t) => t.info.url === url); + } + + /** A blank tab (the one the browser started on) to load an address in, rather than opening another. */ + blank(): T | undefined { + return this.all().find((t) => t.info.url === START_URL); + } + + /** The tabs showing a website (not a blank or browser page). */ + webPages(): T[] { + return this.all().filter((t) => HTTP_URL.test(t.info.url)); + } + + all(): T[] { + return [...this.tabs.values()]; + } + + list(): DrivenTab[] { + return this.all().map((t) => t.info); + } + + /** The browser with these tabs, as listed. */ + described(listedAs: DriverDeps['listedAs'], browser: Pick, version: string | null): DrivenBrowser { + return { ...listedAs, browserId: browser.id, version, tabs: this.list() }; + } + + /** Forgets every tab, and returns them. */ + clear(): T[] { + const all = this.all(); + this.tabs.clear(); + this.arrivals.clear(); + return all; + } +} diff --git a/src/main/browsers/driven/DriverPool.ts b/src/main/browsers/driven/DriverPool.ts new file mode 100644 index 00000000..a899ef64 --- /dev/null +++ b/src/main/browsers/driven/DriverPool.ts @@ -0,0 +1,48 @@ +import type { Driver } from './types'; + +/** The drivers of the browsers driven now, by key, and the ones still connecting: each connected once. */ +export class DriverPool { + private readonly drivers = new Map(); + private readonly starting = new Map>(); + + all(): Driver[] { + return [...this.drivers.values()]; + } + + get size(): number { + return this.drivers.size; + } + + get(key: string): Driver { + const driver = this.drivers.get(key); + if (!driver) throw new Error("That browser isn't open with your changes any more"); + return driver; + } + + /** The driver for `key`: connected, still connecting, or connected now by `start`. */ + reach(key: string, start: () => Promise): Promise { + const driver = this.drivers.get(key); + if (driver) return Promise.resolve(driver); + const starting = + this.starting.get(key) ?? + start() + .then((started) => { + this.drivers.set(key, started); + return started; + }) + .finally(() => this.starting.delete(key)); + this.starting.set(key, starting); + return starting; + } + + remove(key: string): void { + this.drivers.delete(key); + } + + /** Forgets every driver, and returns them. */ + clear(): Driver[] { + const all = this.all(); + this.drivers.clear(); + return all; + } +} diff --git a/src/main/browsers/driven/captureEvery.ts b/src/main/browsers/driven/captureEvery.ts new file mode 100644 index 00000000..e51e0a3d --- /dev/null +++ b/src/main/browsers/driven/captureEvery.ts @@ -0,0 +1,15 @@ +import type { BrowserCapture, Viewport } from '../../shots/capture'; +import { shotBrowser } from './shotBrowser'; +import type { Driver } from './types'; + +/** Captures the whole page at `url` in every driver's browser at once, laid out in `viewport`; one that fails says why. */ +export function captureEvery(drivers: readonly Driver[], url: string, viewport: Viewport): Promise { + return Promise.all( + drivers.map((driver) => + driver.captureAt(url, viewport).then( + (taken) => ({ ...taken, browser: shotBrowser(driver) }), + (err: unknown) => ({ browser: driver.browser.name, reason: err instanceof Error ? err.message : String(err) }), + ), + ), + ); +} diff --git a/src/main/browsers/driven/chromium/ChromiumDesigns.ts b/src/main/browsers/driven/chromium/ChromiumDesigns.ts new file mode 100644 index 00000000..89f6f27e --- /dev/null +++ b/src/main/browsers/driven/chromium/ChromiumDesigns.ts @@ -0,0 +1,55 @@ +import { PageOverlay, type PageDesign } from '../../../overlay'; +import type { TabDesigns } from '../types'; +import { fitTab } from './fitTab'; +import type { DrivenTabState } from './types'; + +/** + * The app's design over a Chromium browser's tabs: each tab its own {@link PageOverlay} on its session (the app page's + * code), the page laid out at the design's width while that is on. + */ +export class ChromiumDesigns implements TabDesigns { + private design: PageDesign | null = null; + /** Each tab's overlay, and which design's image it has (a restyle keeps it). */ + private readonly laid = new Map(); + + async set(design: PageDesign | null, tabs: readonly DrivenTabState[]): Promise { + this.design = design; + await Promise.all(tabs.map((tab) => this.lay(tab, design).catch(() => undefined))); + } + + async found(tab: DrivenTabState): Promise { + if (!this.design) return; + await tab.ready; + await this.lay(tab, this.design).catch(() => undefined); + } + + async hidden(tab: DrivenTabState, task: () => Promise): Promise { + const design = this.design; + if (!design) return task(); + await this.lay(tab, { ...design, settings: { ...design.settings, hidden: true, fitWidth: false } }).catch(() => undefined); + try { + return await task(); + } finally { + if (this.design) await this.lay(tab, this.design).catch(() => undefined); + } + } + + gone(tab: DrivenTabState): void { + this.laid.delete(tab.info.id); + } + + private async lay(tab: DrivenTabState, design: PageDesign | null): Promise { + const laid = this.laid.get(tab.info.id) ?? { overlay: new PageOverlay(tab.transport), key: '' }; + this.laid.set(tab.info.id, laid); + if (!design) { + laid.key = ''; + await laid.overlay.remove(); + return fitTab(tab.transport, null); + } + const { key, base64, width, height, settings } = design; + if (laid.key === key) await laid.overlay.restyle(settings, width, height); + else await laid.overlay.show(base64, settings, width, height); + laid.key = key; + await fitTab(tab.transport, settings.fitWidth ? width : null); + } +} diff --git a/src/main/browsers/driven/chromium/DrivenChromium.ts b/src/main/browsers/driven/chromium/DrivenChromium.ts new file mode 100644 index 00000000..7cf12af6 --- /dev/null +++ b/src/main/browsers/driven/chromium/DrivenChromium.ts @@ -0,0 +1,147 @@ +import type { CaptureArea } from '../../../../shared/types'; +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CdpConnection } from '../../../engine/websocketTransport'; +import { captureOverCdp, type CapturedImage, type Viewport } from '../../../shots/capture'; +import type { FoundBrowser } from '../../types'; +import { attachTab } from './attachTab'; +import { captureTabAt } from './captureTabAt'; +import { NEW_TAB_TIMEOUT_MS, START_URL } from '../constants'; +import { DrivenBase } from '../DrivenBase'; +import type { Driver, DriverDeps, TabRead } from '../types'; +import { ChromiumDesigns } from './ChromiumDesigns'; +import { PAGE_ATTACH, PAGE_TARGET, STOP_ATTACH } from './constants'; +import { letTargetGo } from './letTargetGo'; +import { readTabInfos } from './readTabInfos'; +import { releaseTab } from './releaseTab'; +import { reloadTab } from './reloadTab'; +import type { AttachedPage, DrivenTabState, PageTargetInfo } from './types'; + +/** + * A Chromium browser the app launched, over its browser-level CDP connection: every tab is attached (a new one before + * it loads anything) and served the workspace's overrides and rules by an interception of its own, the same engine as + * the app's page. Tabs are listed with their address and title as they change. + */ +export class DrivenChromium extends DrivenBase implements Driver { + protected readonly designs = new ChromiumDesigns(); + + constructor( + browser: FoundBrowser, + version: string | null, + protected readonly connection: CdpConnection, + deps: DriverDeps, + ) { + super(browser, version, deps); + } + + /** Attaches to the browser's tabs, the ones open now and every one opened later. */ + async start(): Promise { + await this.listen(); + await this.connection.send(CDP.Target.setAutoAttach, { ...PAGE_ATTACH }); + } + + /** Follows the browser's tabs attached (and their address and title), and the connection closing. */ + protected async listen(): Promise { + const events: Partial void>> = { + [CDP.Target.attachedToTarget]: (p: AttachedPage) => this.attached(p), + [CDP.Target.detachedFromTarget]: (p: { sessionId: string }) => this.detached(p.sessionId), + [CDP.Target.targetInfoChanged]: (p: { targetInfo: PageTargetInfo }) => this.tabs.update(p.targetInfo.targetId, p.targetInfo) && this.deps.changed(), + }; + this.disposers.push( + // The browser's own events carry no session; its tabs' go to their interceptions. + this.connection.onEvent((method, params, sessionId) => { + if (!sessionId) events[method]?.(params); + }), + this.connection.onClose(() => this.closed()), + ); + await this.connection.send(CDP.Target.setDiscoverTargets, { discover: true }); + } + + /** Opens an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ + async open(url: string): Promise { + const tab = this.tabs.blank() ?? (await this.newTab()); + // Taken: another address opened before this one shows isn't loaded in it too. + tab.info = { ...tab.info, url }; + await tab.ready; + await tab.transport.send(CDP.Page.navigate, { url }); + await this.activate(tab.info.id); + return tab; + } + + async activate(tabId: string): Promise { + this.tabs.get(tabId); + await this.connection.send(CDP.Target.activateTarget, { targetId: tabId }); + } + + /** After overrides or rules changed: what each tab intercepts. */ + async refresh(): Promise { + await Promise.all(this.tabs.all().map((t) => t.interception.refreshInterception().catch(() => undefined))); + } + + /** After the settings changed: the cache, throttling and service workers they set, in each tab. */ + async applySettings(): Promise { + await Promise.all(this.tabs.all().map((t) => t.interception.applySettings().catch(() => undefined))); + } + + /** Reloads the tabs showing a website, so what changed is served. */ + async reload(): Promise { + await Promise.all(this.tabs.webPages().map((t) => reloadTab(t).catch(() => undefined))); + } + + /** Stops serving the workspace's changes: the browser stays open, as it is. */ + stop(): void { + this.dispose(); + for (const tab of this.tabs.clear()) releaseTab(tab); + this.connection.send(CDP.Target.setAutoAttach, { ...STOP_ATTACH }).catch(() => undefined); + this.connection.close(); + } + + protected attached(p: AttachedPage): void { + // Only tabs are asked for; anything else is let go of as it came. + if (p.targetInfo.type !== PAGE_TARGET) return letTargetGo(this.connection, p.sessionId); + if (this.tabs.has(p.targetInfo.targetId)) return; + const tab = attachTab(this.connection, p, this.deps.sources); + tab.transport.on(CDP.Page.loadEventFired, () => void this.readTabs([tab.info.id])); + this.tabs.add(tab); + this.deps.changed(); + void this.designs.found(tab); + } + + private detached(sessionId: string): void { + const tab = this.tabs.all().find((t) => t.sessionId === sessionId); + if (!tab) return; + this.tabs.remove(tab.info.id); + this.designs.gone(tab); + releaseTab(tab); + this.deps.changed(); + } + + protected read(ids: string[]): Promise { + return readTabInfos(this.connection, ids); + } + + protected take(tab: DrivenTabState, area: Exclude): Promise { + return captureOverCdp(tab.transport, { area }); + } + + protected openLoaded(url: string): Promise { + // Page.navigate answers once the page is committed: the capture waits for its load. + return this.open(url); + } + + protected takeAt(tab: DrivenTabState, viewport: Viewport): Promise { + return captureTabAt(tab, viewport); + } + + protected async newTab(): Promise { + const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); + return withTimeout(this.tabs.arrival(targetId), NEW_TAB_TIMEOUT_MS, 'Opening a tab'); + } + + /** The browser was quit: its tabs are gone with it. */ + private closed(): void { + this.dispose(); + for (const tab of this.tabs.clear()) tab.interception.detach(); + this.deps.closed(); + } +} diff --git a/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts b/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts new file mode 100644 index 00000000..1dfc991f --- /dev/null +++ b/src/main/browsers/driven/chromium/DrivenEverydayChrome.ts @@ -0,0 +1,23 @@ +import { CDP } from '../../../engine/constants'; +import { START_URL } from '../constants'; +import { PAGE_TARGET } from './constants'; +import { DrivenChromium } from './DrivenChromium'; +import type { DrivenTabState } from './types'; + +/** + * Your everyday Chromium browser, remote debugging turned on for it: only the tabs the app opens there are attached and + * served the workspace's changes. Your own tabs are neither listed nor touched, and letting go of it leaves it as it was. + */ +export class DrivenEverydayChrome extends DrivenChromium { + override async start(): Promise { + await this.listen(); + } + + protected override async newTab(): Promise { + const { targetId } = await this.connection.send<{ targetId: string }>(CDP.Target.createTarget, { url: START_URL }); + const { sessionId } = await this.connection.send<{ sessionId: string }>(CDP.Target.attachToTarget, { targetId, flatten: true }); + // Its interception is set up before anything loads in it (the attach event, if it came first, did the same). + this.attached({ sessionId, targetInfo: { targetId, type: PAGE_TARGET, url: START_URL, title: '' } }); + return this.tabs.get(targetId); + } +} diff --git a/src/main/browsers/driven/chromium/attachTab.ts b/src/main/browsers/driven/chromium/attachTab.ts new file mode 100644 index 00000000..4b5c9cde --- /dev/null +++ b/src/main/browsers/driven/chromium/attachTab.ts @@ -0,0 +1,31 @@ +import { CDP } from '../../../engine/constants'; +import { PageInterception } from '../../../engine/PageInterception'; +import { pageTransport, type CdpConnection } from '../../../engine/websocketTransport'; +import type { InterceptionSources } from '../types'; +import type { AttachedPage, DrivenTabState } from './types'; + +/** + * Serves the workspace's changes in a tab just attached: an interception of its own on its session, set up before + * the tab (paused, when it is new) is let run. + */ +export function attachTab(connection: CdpConnection, { sessionId, targetInfo }: AttachedPage, { store, rules, settings }: InterceptionSources): DrivenTabState { + const transport = pageTransport(connection, sessionId); + const interception = new PageInterception({ + transport, + getOverrides: () => store.list(), + getRules: () => rules.list(), + getSettings: () => settings.get(), + getOverrideBase: (id) => store.base(id), + // The app's network list and console are its own page's: nothing is reported from other browsers. + emit: () => undefined, + }); + const ready = interception + .attach() + .catch(() => undefined) + .then(() => transport.send(CDP.Runtime.runIfWaitingForDebugger)) + .then( + () => undefined, + () => undefined, + ); + return { info: { id: targetInfo.targetId, title: targetInfo.title, url: targetInfo.url }, sessionId, transport, interception, ready }; +} diff --git a/src/main/browsers/driven/chromium/captureTabAt.ts b/src/main/browsers/driven/chromium/captureTabAt.ts new file mode 100644 index 00000000..e79c5f11 --- /dev/null +++ b/src/main/browsers/driven/chromium/captureTabAt.ts @@ -0,0 +1,13 @@ +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import { atViewport, captureOverCdp, type CapturedImage, type Viewport } from '../../../shots/capture'; +import { LOADED_EXPRESSION } from '../../../shots/constants'; +import { LOAD_TIMEOUT_MS } from '../constants'; +import type { DrivenTabState } from './types'; + +/** Captures the whole page a tab shows, laid out in `viewport`, once it has loaded and been quiet a moment. */ +export async function captureTabAt(tab: DrivenTabState, viewport: Viewport): Promise { + // A page still loading after that is captured as it is. + await withTimeout(tab.transport.send(CDP.Runtime.evaluate, { expression: LOADED_EXPRESSION, awaitPromise: true }), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); + return atViewport(tab.transport, viewport, () => captureOverCdp(tab.transport, { area: 'page' })); +} diff --git a/src/main/browsers/driven/chromium/connectChromium.ts b/src/main/browsers/driven/chromium/connectChromium.ts new file mode 100644 index 00000000..bf41e240 --- /dev/null +++ b/src/main/browsers/driven/chromium/connectChromium.ts @@ -0,0 +1,20 @@ +import { CDP } from '../../../engine/constants'; +import { CdpConnection } from '../../../engine/websocketTransport'; +import type { FoundBrowser } from '../../types'; +import { profileDir } from '../profileDir'; +import { reachOrLaunch } from '../reachOrLaunch'; +import type { Driver, DriverDeps } from '../types'; +import { PRODUCT_VERSION } from './constants'; +import { DrivenChromium } from './DrivenChromium'; +import { launchChromium } from './launchChromium'; +import { readActivePort } from './readActivePort'; + +/** Drives a Chromium browser over CDP: the one still open with the app's profile for it, or one launched now. */ +export async function connectChromium(browser: FoundBrowser, deps: DriverDeps): Promise { + const dir = profileDir(browser, deps.userData); + const connection = await reachOrLaunch(() => readActivePort(dir), (address) => CdpConnection.connect(address), () => launchChromium(browser, dir)); + const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); + const driver = new DrivenChromium(browser, PRODUCT_VERSION.exec(product)?.[1] ?? null, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/chromium/constants.ts b/src/main/browsers/driven/chromium/constants.ts new file mode 100644 index 00000000..99fb43cc --- /dev/null +++ b/src/main/browsers/driven/chromium/constants.ts @@ -0,0 +1,22 @@ +/** The file Chromium writes in its profile when started with a debugging port: the port, then the browser's path. */ +export const ACTIVE_PORT_FILE = 'DevToolsActivePort'; + +/** What a browser started to be driven is given: a debugging port the system picks, and no first-run pages. */ +export const DRIVE_FLAGS = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check'] as const; +export const USER_DATA_FLAG = '--user-data-dir='; +export const DISABLE_FEATURES_FLAG = '--disable-features='; + +/** The target type of a tab. */ +export const PAGE_TARGET = 'page'; + +/** + * Every tab of a driven browser is attached, and one opened later waits (before it loads anything) until its + * interception is set up. Its frames and workers are attached below it, by its own interception. + */ +export const PAGE_ATTACH = { autoAttach: true, waitForDebuggerOnStart: true, flatten: true, filter: [{ type: PAGE_TARGET }, { exclude: true }] } as const; + +/** Letting go of a driven browser's tabs: new ones start as they would. */ +export const STOP_ATTACH = { autoAttach: false, waitForDebuggerOnStart: false, flatten: true } as const; + +/** `Browser.getVersion`'s product: `Chrome/140.0.7339.80`, the version after the slash. */ +export const PRODUCT_VERSION = /\/(\S+)$/; diff --git a/src/main/browsers/driven/chromium/fitTab.ts b/src/main/browsers/driven/chromium/fitTab.ts new file mode 100644 index 00000000..b0f0cedd --- /dev/null +++ b/src/main/browsers/driven/chromium/fitTab.ts @@ -0,0 +1,9 @@ +import type { CdpTransport } from '../../../engine/cdp'; +import { CDP } from '../../../engine/constants'; +import { FIT_METRICS } from '../constants'; + +/** Lays a Chromium tab's page out `width` CSS pixels wide (a design's), its window's height kept; null: its own width. */ +export async function fitTab(transport: CdpTransport, width: number | null): Promise { + if (width === null) await transport.send(CDP.Emulation.clearDeviceMetricsOverride); + else await transport.send(CDP.Emulation.setDeviceMetricsOverride, { width, ...FIT_METRICS }); +} diff --git a/src/main/browsers/driven/chromium/launchChromium.ts b/src/main/browsers/driven/chromium/launchChromium.ts new file mode 100644 index 00000000..deb150f6 --- /dev/null +++ b/src/main/browsers/driven/chromium/launchChromium.ts @@ -0,0 +1,11 @@ +import { LOCAL_NETWORK_ACCESS_FEATURES } from '../../../chromiumFlags'; +import type { FoundBrowser } from '../../types'; +import { launchDriven } from '../launchDriven'; +import { ACTIVE_PORT_FILE, DISABLE_FEATURES_FLAG, DRIVE_FLAGS, USER_DATA_FLAG } from './constants'; +import { readActivePort } from './readActivePort'; + +/** Starts a Chromium browser to be driven: its profile in `dir`, a debugging port, the app's Chromium switches. */ +export function launchChromium(browser: FoundBrowser, dir: string): Promise { + const flags = [`${USER_DATA_FLAG}${dir}`, ...DRIVE_FLAGS, `${DISABLE_FEATURES_FLAG}${LOCAL_NETWORK_ACCESS_FEATURES.join(',')}`]; + return launchDriven(browser, dir, { flags, portFile: ACTIVE_PORT_FILE, read: readActivePort }); +} diff --git a/src/main/browsers/driven/chromium/letTargetGo.ts b/src/main/browsers/driven/chromium/letTargetGo.ts new file mode 100644 index 00000000..2a4c7e0f --- /dev/null +++ b/src/main/browsers/driven/chromium/letTargetGo.ts @@ -0,0 +1,8 @@ +import { CDP } from '../../../engine/constants'; +import type { CdpConnection } from '../../../engine/websocketTransport'; + +/** Lets a target that isn't a tab go on as it came: run, and no longer attached. */ +export function letTargetGo(connection: CdpConnection, sessionId: string): void { + connection.send(CDP.Runtime.runIfWaitingForDebugger, {}, sessionId).catch(() => undefined); + connection.send(CDP.Target.detachFromTarget, { sessionId }).catch(() => undefined); +} diff --git a/src/main/browsers/driven/chromium/readActivePort.ts b/src/main/browsers/driven/chromium/readActivePort.ts new file mode 100644 index 00000000..dec77761 --- /dev/null +++ b/src/main/browsers/driven/chromium/readActivePort.ts @@ -0,0 +1,10 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ACTIVE_PORT_FILE } from './constants'; + +/** The WebSocket address of the browser whose profile is `dir`, from the file it wrote there; null when there is none (yet). */ +export async function readActivePort(dir: string): Promise { + const text = await readFile(join(dir, ACTIVE_PORT_FILE), 'utf8').catch(() => ''); + const [port, path] = text.split(/\r?\n/); + return /^\d+$/.test(port ?? '') && path?.startsWith('/') ? `ws://127.0.0.1:${port}${path}` : null; +} diff --git a/src/main/browsers/driven/chromium/readTabInfos.ts b/src/main/browsers/driven/chromium/readTabInfos.ts new file mode 100644 index 00000000..44141030 --- /dev/null +++ b/src/main/browsers/driven/chromium/readTabInfos.ts @@ -0,0 +1,10 @@ +import { CDP } from '../../../engine/constants'; +import type { CdpConnection } from '../../../engine/websocketTransport'; +import type { TabRead } from '../types'; +import type { PageTargetInfo } from './types'; + +/** What the browser says of each tab now (its title, which isn't announced as it changes); a tab gone is left out. */ +export async function readTabInfos(connection: CdpConnection, ids: string[]): Promise { + const infos = await Promise.all(ids.map((targetId) => connection.send<{ targetInfo: PageTargetInfo }>(CDP.Target.getTargetInfo, { targetId }).then(({ targetInfo }) => targetInfo, () => null))); + return infos.filter((info) => info !== null).map(({ targetId, url, title }) => ({ id: targetId, url, title })); +} diff --git a/src/main/browsers/driven/chromium/releaseTab.ts b/src/main/browsers/driven/chromium/releaseTab.ts new file mode 100644 index 00000000..64411f06 --- /dev/null +++ b/src/main/browsers/driven/chromium/releaseTab.ts @@ -0,0 +1,7 @@ +import type { DrivenTabState } from './types'; + +/** Stops serving a tab: its interception goes, and its session is let go of. */ +export function releaseTab(tab: DrivenTabState): void { + tab.interception.detach(); + void tab.transport.detach(); +} diff --git a/src/main/browsers/driven/chromium/reloadTab.ts b/src/main/browsers/driven/chromium/reloadTab.ts new file mode 100644 index 00000000..1a0c7ae4 --- /dev/null +++ b/src/main/browsers/driven/chromium/reloadTab.ts @@ -0,0 +1,8 @@ +import { CDP } from '../../../engine/constants'; +import type { DrivenTabState } from './types'; + +/** Reloads a tab from the network, first asking service workers that run outdated code to unregister. */ +export async function reloadTab(tab: DrivenTabState): Promise { + await tab.interception.prepareReload(tab.info.url); + await tab.transport.send(CDP.Page.reload, { ignoreCache: true }); +} diff --git a/src/main/browsers/driven/chromium/types.ts b/src/main/browsers/driven/chromium/types.ts new file mode 100644 index 00000000..137a8de9 --- /dev/null +++ b/src/main/browsers/driven/chromium/types.ts @@ -0,0 +1,26 @@ +import type { DrivenTab } from '../../../../shared/types'; +import type { PageInterception } from '../../../engine/PageInterception'; +import type { PageTransport } from '../../../engine/websocketTransport'; + +/** A tab of a driven Chromium browser, with the interception serving it. */ +export interface DrivenTabState { + info: DrivenTab; + sessionId: string; + transport: PageTransport; + interception: PageInterception; + /** Settles once its interception is set up (or couldn't be) and the tab runs. */ + ready: Promise; +} + +/** What `Target.attachedToTarget` and `Target.targetInfoChanged` say of a target, as far as a tab needs. */ +export interface PageTargetInfo { + targetId: string; + type: string; + url: string; + title: string; +} + +export interface AttachedPage { + sessionId: string; + targetInfo: PageTargetInfo; +} diff --git a/src/main/browsers/driven/connectorOf.ts b/src/main/browsers/driven/connectorOf.ts new file mode 100644 index 00000000..6b2892ab --- /dev/null +++ b/src/main/browsers/driven/connectorOf.ts @@ -0,0 +1,21 @@ +import type { DrivenEngine } from '../../../shared/types'; +import type { FoundBrowser } from '../types'; +import { WEBKIT_ID } from '../webkit/constants'; +import { connectChromium } from './chromium/connectChromium'; +import { connectEverydayChrome } from './everyday/connectEverydayChrome'; +import { connectFirefox } from './firefox/connectFirefox'; +import { connectWebKit } from './playwright'; +import type { ConnectDriver } from './types'; + +/** How a browser of each engine is driven (the UI offers what `DRIVEN_ENGINES` lists); another can't be served the workspace's changes. */ +const DRIVERS: Readonly> = { chromium: connectChromium, gecko: connectFirefox }; + +/** + * How a browser is driven with the workspace's changes: by its engine, the WebKit build the app downloads through + * Playwright, or your everyday Chromium browser (`everyday`) as it runs; none for another (Safari). + */ +export function connectorOf(browser: FoundBrowser, everyday: boolean): ConnectDriver | undefined { + if (everyday) return browser.engine === 'chromium' ? connectEverydayChrome : undefined; + if (browser.id === WEBKIT_ID) return connectWebKit; + return Object.hasOwn(DRIVERS, browser.engine) ? DRIVERS[browser.engine as DrivenEngine] : undefined; +} diff --git a/src/main/browsers/driven/constants.ts b/src/main/browsers/driven/constants.ts new file mode 100644 index 00000000..32df3aa3 --- /dev/null +++ b/src/main/browsers/driven/constants.ts @@ -0,0 +1,35 @@ +/** Where a driven browser's profile is kept, in the app's data folder (one folder per browser). */ +export const PROFILES_DIR = 'browsers'; + +/** The profile folder's name inside a Snap's or Flatpak's own writable folder. */ +export const SANDBOXED_PROFILE = 'console-editor-profile'; + +/** A Snap's own writable folder: `~/snap//common`; a Flatpak's: `~/.var/app//data`. */ +export const SANDBOX_DIRS = { snapBin: '/snap/bin/', snap: 'snap', snapCommon: 'common', flatpak: 'flatpak', flatpakRun: 'run', flatpakData: '.var/app', flatpakDataSub: 'data' } as const; + +/** Waiting for a launched browser to write the address of its debugging port in its profile. */ +export const PORT_WAIT = { timeoutMs: 30_000, stepMs: 150 } as const; + +/** The page a driven browser starts on: nothing loads before the app has attached to it. */ +export const START_URL = 'about:blank'; + +/** macOS: `open -n -a --args ` starts a new instance with flags. */ +export const MAC_OPEN = { newInstance: '-n', args: '--args' } as const; + +/** Flatpak's markers around forwarded files: flags go before them. */ +export const FLATPAK_MARKERS: readonly string[] = ['@@u', '@@']; + +/** A tab's window (CSS pixels), density and document size, read in its page as JSON. */ +export const METRICS_EXPRESSION = 'JSON.stringify([innerWidth, innerHeight, devicePixelRatio, document.documentElement.scrollWidth, document.documentElement.scrollHeight])'; + +/** How long a tab captured in every browser may take to load before it is captured as it is. */ +export const LOAD_TIMEOUT_MS = 30_000; + +/** How long a tab the app opens has to be attached before opening it fails. */ +export const NEW_TAB_TIMEOUT_MS = 10_000; + +/** Waiting this long after overrides or rules change before reloading driven tabs (a burst reloads them once). */ +export const RELOAD_DEBOUNCE_MS = 300; + +/** A design's width laid out in another browser's tab: its window keeps its height (0: not overridden) and density. */ +export const FIT_METRICS = { height: 0, deviceScaleFactor: 0, mobile: false } as const; diff --git a/src/main/browsers/driven/driveCommand.ts b/src/main/browsers/driven/driveCommand.ts new file mode 100644 index 00000000..720390ed --- /dev/null +++ b/src/main/browsers/driven/driveCommand.ts @@ -0,0 +1,14 @@ +import { FLATPAK_MARKERS, MAC_OPEN } from './constants'; +import type { BrowserCommand } from '../types'; +import { OPEN_COMMAND } from '../findBrowsers/mac/constants'; + +/** + * A browser's command with `flags` and `url` added so the browser takes them: after `--args` for a macOS app (in a + * new instance), before Flatpak's forwarded-file markers, else where the address goes. + */ +export function driveCommand({ command, urlAt }: BrowserCommand, flags: string[], url: string): string[] { + if (command[0] === OPEN_COMMAND) return [command[0], MAC_OPEN.newInstance, ...command.slice(1), MAC_OPEN.args, ...flags, url]; + const marker = command.findIndex((arg) => FLATPAK_MARKERS.includes(arg)); + const at = marker >= 0 ? Math.min(marker, urlAt) : urlAt; + return [...command.slice(0, at), ...flags, url, ...command.slice(at).filter((arg) => !FLATPAK_MARKERS.includes(arg))]; +} diff --git a/src/main/browsers/driven/everyday/connectEverydayChrome.ts b/src/main/browsers/driven/everyday/connectEverydayChrome.ts new file mode 100644 index 00000000..547e8c67 --- /dev/null +++ b/src/main/browsers/driven/everyday/connectEverydayChrome.ts @@ -0,0 +1,27 @@ +import { CDP } from '../../../engine/constants'; +import { withTimeout } from '../../../engine/PageInterception'; +import { CdpConnection } from '../../../engine/websocketTransport'; +import type { FoundBrowser } from '../../types'; +import { PRODUCT_VERSION } from '../chromium/constants'; +import { DrivenEverydayChrome } from '../chromium/DrivenEverydayChrome'; +import type { Driver, DriverDeps } from '../types'; +import { ALLOW_TIMEOUT_MS } from './constants'; +import { reachableEveryday } from './reachableEveryday'; + +/** + * Drives your everyday Chromium browser with the workspace's changes, over the debugging port turned on for it + * (chrome://inspect/#remote-debugging): the browser asks you to allow the connection first. + */ +export async function connectEverydayChrome(browser: FoundBrowser, deps: DriverDeps): Promise { + const address = await reachableEveryday(browser, deps.home); + if (!address) throw new Error(`Turn on remote debugging in ${browser.name} first: chrome://inspect/#remote-debugging`); + const reached = (async () => { + const connection = await CdpConnection.connect(address); + const { product } = await connection.send<{ product: string }>(CDP.Browser.getVersion); + return { connection, version: PRODUCT_VERSION.exec(product)?.[1] ?? null }; + })(); + const { connection, version } = await withTimeout(reached, ALLOW_TIMEOUT_MS, `Waiting for ${browser.name} to allow the connection`); + const driver = new DrivenEverydayChrome(browser, version, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/everyday/constants.ts b/src/main/browsers/driven/everyday/constants.ts new file mode 100644 index 00000000..5e73db14 --- /dev/null +++ b/src/main/browsers/driven/everyday/constants.ts @@ -0,0 +1,33 @@ +/** + * Where each Chromium browser keeps its everyday profile, by the words its name or program has (first match first): + * under `~/.config` on Linux, `~/Library/Application Support` on macOS, `%LOCALAPPDATA%` on Windows. With remote + * debugging turned on for it (Chrome 144's chrome://inspect/#remote-debugging), it writes its address there. + */ +export const EVERYDAY_PROFILES: ReadonlyArray<{ pattern: RegExp; linux: string; darwin: string; win32: string }> = [ + { pattern: /chrome.*beta/i, linux: 'google-chrome-beta', darwin: 'Google/Chrome Beta', win32: 'Google/Chrome Beta/User Data' }, + { pattern: /chrome.*(unstable|dev)/i, linux: 'google-chrome-unstable', darwin: 'Google/Chrome Dev', win32: 'Google/Chrome Dev/User Data' }, + { pattern: /canary/i, linux: 'google-chrome-canary', darwin: 'Google/Chrome Canary', win32: 'Google/Chrome SxS/User Data' }, + { pattern: /chromium/i, linux: 'chromium', darwin: 'Chromium', win32: 'Chromium/User Data' }, + { pattern: /chrome/i, linux: 'google-chrome', darwin: 'Google/Chrome', win32: 'Google/Chrome/User Data' }, + { pattern: /edge/i, linux: 'microsoft-edge', darwin: 'Microsoft Edge', win32: 'Microsoft/Edge/User Data' }, + { pattern: /brave/i, linux: 'BraveSoftware/Brave-Browser', darwin: 'BraveSoftware/Brave-Browser', win32: 'BraveSoftware/Brave-Browser/User Data' }, + { pattern: /vivaldi/i, linux: 'vivaldi', darwin: 'Vivaldi', win32: 'Vivaldi/User Data' }, + { pattern: /opera/i, linux: 'opera', darwin: 'com.operasoftware.Opera', win32: 'Opera Software/Opera Stable' }, + { pattern: /\barc\b/i, linux: 'arc', darwin: 'Arc/User Data', win32: 'Arc/User Data' }, +]; + +/** Each system's folder of profiles, from the home folder (Windows: its local app data). */ +export const PROFILES_ROOT: Partial> = { linux: '.config', darwin: 'Library/Application Support', win32: 'AppData/Local' }; + +/** Where a Snap's (`~/snap//common/`) and a Flatpak's (`~/.var/app//config/`) everyday profiles are. */ +export const SANDBOXED_PROFILES = { snapCommon: 'common', flatpakData: '.var/app', flatpakConfig: 'config' } as const; + +/** How long a debugging port gets to take a connection before it counts as closed (the file can outlive the browser). */ +export const LISTENING_TIMEOUT_MS = 500; + +/** How long connecting may take: Chrome asks you to allow it first. */ +export const ALLOW_TIMEOUT_MS = 60_000; + +/** A driven everyday browser's key, and its name, beside the installed browser's. */ +export const EVERYDAY_KEY_SUFFIX = '#everyday'; +export const EVERYDAY_NAME_SUFFIX = ' · your profile'; diff --git a/src/main/browsers/driven/everyday/everydayProfileDir.ts b/src/main/browsers/driven/everyday/everydayProfileDir.ts new file mode 100644 index 00000000..5980f30c --- /dev/null +++ b/src/main/browsers/driven/everyday/everydayProfileDir.ts @@ -0,0 +1,23 @@ +import { basename, join } from 'node:path'; +import { SANDBOX_DIRS } from '../constants'; +import type { FoundBrowser } from '../../types'; +import { EVERYDAY_PROFILES, PROFILES_ROOT, SANDBOXED_PROFILES } from './constants'; + +/** + * The folder of a Chromium browser's everyday profile, by its name or program: in its system's usual place, or where + * its Snap or Flatpak keeps it. Null for a browser (or system) the app doesn't know it for. + */ +export function everydayProfileDir(browser: FoundBrowser, home: string, platform: NodeJS.Platform = process.platform): string | null { + const names = `${browser.name} ${browser.program ?? ''} ${browser.id}`; + const profile = EVERYDAY_PROFILES.find((p) => p.pattern.test(names)); + const root = PROFILES_ROOT[platform]; + if (!profile || !root || !(platform in profile)) return null; + const folder = profile[platform as keyof typeof profile] as string; + const program = browser.program ?? ''; + if (program.startsWith(SANDBOX_DIRS.snapBin)) return join(home, SANDBOX_DIRS.snap, basename(program), SANDBOXED_PROFILES.snapCommon, folder); + if (basename(program) === SANDBOX_DIRS.flatpak) { + const appId = browser.command.slice(browser.command.indexOf(SANDBOX_DIRS.flatpakRun) + 1).find((arg) => !arg.startsWith('-')); + if (appId) return join(home, SANDBOXED_PROFILES.flatpakData, appId, SANDBOXED_PROFILES.flatpakConfig, folder); + } + return join(home, root, folder); +} diff --git a/src/main/browsers/driven/everyday/isListening.ts b/src/main/browsers/driven/everyday/isListening.ts new file mode 100644 index 00000000..3362a66f --- /dev/null +++ b/src/main/browsers/driven/everyday/isListening.ts @@ -0,0 +1,19 @@ +import { connect } from 'node:net'; +import { LISTENING_TIMEOUT_MS } from './constants'; + +/** + * Whether something takes connections on a loopback port (a TCP connection opened and closed at once: nothing is sent, + * so Chrome doesn't ask you to allow anything). + */ +export function isListening(port: number): Promise { + return new Promise((resolve) => { + const socket = connect({ host: '127.0.0.1', port }); + const done = (listening: boolean) => { + socket.destroy(); + resolve(listening); + }; + socket.setTimeout(LISTENING_TIMEOUT_MS, () => done(false)); + socket.once('connect', () => done(true)); + socket.once('error', () => done(false)); + }); +} diff --git a/src/main/browsers/driven/everyday/reachableEveryday.ts b/src/main/browsers/driven/everyday/reachableEveryday.ts new file mode 100644 index 00000000..7fc487ed --- /dev/null +++ b/src/main/browsers/driven/everyday/reachableEveryday.ts @@ -0,0 +1,15 @@ +import type { FoundBrowser } from '../../types'; +import { readActivePort } from '../chromium/readActivePort'; +import { everydayProfileDir } from './everydayProfileDir'; +import { isListening } from './isListening'; + +/** + * The debugging address of a Chromium browser's everyday profile, while remote debugging is on for it and it runs + * (its port takes connections); null otherwise. + */ +export async function reachableEveryday(browser: FoundBrowser, home: string, platform: NodeJS.Platform = process.platform): Promise { + const dir = browser.engine === 'chromium' ? everydayProfileDir(browser, home, platform) : null; + const address = dir ? await readActivePort(dir) : null; + const port = address ? Number(new URL(address).port) : 0; + return port && (await isListening(port)) ? address : null; +} diff --git a/src/main/browsers/driven/firefox/DrivenFirefox.ts b/src/main/browsers/driven/firefox/DrivenFirefox.ts new file mode 100644 index 00000000..b891013f --- /dev/null +++ b/src/main/browsers/driven/firefox/DrivenFirefox.ts @@ -0,0 +1,135 @@ +import type { CaptureArea } from '../../../../shared/types'; +import { BIDI, BidiInterception, type BidiConnection } from '../../../engine/bidi'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import type { FoundBrowser } from '../../types'; +import { LOAD_TIMEOUT_MS, START_URL } from '../constants'; +import { DrivenBase } from '../DrivenBase'; +import type { Driver, DriverDeps, KeptTab, TabRead } from '../types'; +import { captureContext } from './captureContext'; +import { captureContextAt } from './captureContextAt'; +import { FirefoxDesigns } from './FirefoxDesigns'; +import { NAVIGATE_WAIT, TAB_TYPE } from './constants'; +import { readFirefoxTabs } from './readFirefoxTabs'; +import type { ContextInfo } from './types'; + +/** + * Firefox, launched by the app, over its WebDriver BiDi session: the workspace's overrides and rules served in every + * tab by one interception (see {@link BidiInterception}), and its tabs (top-level browsing contexts) listed with their + * address and title as they change. + */ +export class DrivenFirefox extends DrivenBase implements Driver { + private readonly interception: BidiInterception; + protected readonly designs: FirefoxDesigns; + + constructor( + browser: FoundBrowser, + version: string | null, + private readonly connection: BidiConnection, + deps: DriverDeps, + ) { + super(browser, version, deps); + this.designs = new FirefoxDesigns(connection); + const { store, rules, settings } = deps.sources; + this.interception = new BidiInterception(connection, { getOverrides: () => store.list(), getRules: () => rules.list(), getSettings: () => settings.get() }); + } + + async start(): Promise { + const moved = (p: ContextInfo) => this.tabs.update(p.context, { url: p.url }) && this.deps.changed(); + const events: Partial void>> = { + [BIDI.browsingContext.contextCreated]: (p: ContextInfo) => !p.parent && this.found(p.context, p.url), + [BIDI.browsingContext.contextDestroyed]: (p: ContextInfo) => !p.parent && this.tabs.remove(p.context) && this.deps.changed(), + [BIDI.browsingContext.navigationStarted]: moved, + [BIDI.browsingContext.fragmentNavigated]: moved, + [BIDI.browsingContext.historyUpdated]: moved, + // A title a page sets isn't reported: it is read once the page has loaded. + [BIDI.browsingContext.load]: (p: ContextInfo) => void this.readTabs([p.context]), + }; + this.disposers.push( + this.connection.onEvent((method, params) => events[method]?.(params)), + this.connection.onClose(() => this.closed()), + ); + await this.connection.send(BIDI.session.subscribe, { events: Object.keys(events) }); + await this.interception.start(); + const { contexts } = await this.connection.send<{ contexts: ContextInfo[] }>(BIDI.browsingContext.getTree, { maxDepth: 0 }); + for (const context of contexts) this.found(context.context, context.url); + await this.readTabs(); + } + + open(url: string): Promise { + return this.load(url, NAVIGATE_WAIT.none); + } + + async activate(tabId: string): Promise { + this.tabs.get(tabId); + await this.connection.send(BIDI.browsingContext.activate, { context: tabId }); + } + + + refresh(): Promise { + return this.interception.refresh(); + } + + applySettings(): Promise { + return this.interception.applySettings(); + } + + async reload(): Promise { + await Promise.all(this.tabs.webPages().map((t) => this.connection.send(BIDI.browsingContext.reload, { context: t.info.id, wait: NAVIGATE_WAIT.none }).catch(() => undefined))); + } + + stop(): void { + this.dispose(); + this.interception.stop(); + this.tabs.clear(); + this.connection.send(BIDI.session.end).catch(() => undefined); + this.connection.close(); + } + + protected read(ids: string[]): Promise { + return readFirefoxTabs(this.connection, ids); + } + + protected take(tab: KeptTab, area: Exclude): Promise { + return captureContext(this.connection, tab.info.id, area); + } + + protected openLoaded(url: string): Promise { + return withTimeout(this.load(url, NAVIGATE_WAIT.loaded), LOAD_TIMEOUT_MS, 'Loading the page'); + } + + protected takeAt(tab: KeptTab, viewport: Viewport): Promise { + return captureContextAt(this.connection, tab.info.id, viewport); + } + + /** Loads an address in the blank tab the browser started on, or else in a new tab, and brings it to the front. */ + private async load(url: string, wait: string): Promise { + const tab = this.tabs.blank() ?? (await this.newTab()); + // Taken: another address opened before this one shows isn't loaded in it too. + tab.info = { ...tab.info, url }; + await this.connection.send(BIDI.browsingContext.navigate, { context: tab.info.id, url, wait }); + await this.activate(tab.info.id); + return tab; + } + + private async newTab(): Promise { + const { context } = await this.connection.send<{ context: string }>(BIDI.browsingContext.create, { type: TAB_TYPE }); + this.found(context, START_URL); + return this.tabs.get(context); + } + + private found(id: string, url: string): void { + if (this.tabs.has(id)) return; + const tab = { info: { id, title: '', url } }; + this.tabs.add(tab); + this.deps.changed(); + void this.designs.found(tab); + } + + /** The browser was quit: its tabs are gone with it. */ + private closed(): void { + this.dispose(); + this.tabs.clear(); + this.deps.closed(); + } +} diff --git a/src/main/browsers/driven/firefox/FirefoxDesigns.ts b/src/main/browsers/driven/firefox/FirefoxDesigns.ts new file mode 100644 index 00000000..655f968e --- /dev/null +++ b/src/main/browsers/driven/firefox/FirefoxDesigns.ts @@ -0,0 +1,84 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { designStyleCall, OVERLAY_JS, OVERLAY_WORLD, overlayCall, type PageDesign } from '../../../overlay'; +import type { KeptTab, TabDesigns } from '../types'; +import { withDesignAside } from '../withDesignAside'; +import { HEIGHT_EXPRESSION } from './constants'; +import { evaluateIn } from './evaluateIn'; + +/** + * The app's design over Firefox's tabs: the app page's overlay code, style and image as preload scripts in a sandbox + * of every new document (a page's scripts can't reach it), run in each tab's document loaded now; the page laid out at + * the design's width (BiDi's viewport) while that is on. + */ +export class FirefoxDesigns implements TabDesigns { + private design: PageDesign | null = null; + private scripts: { code?: string; style?: string; image?: string } = {}; + private key = ''; + + constructor(private readonly connection: BidiConnection) {} + + async set(design: PageDesign | null, tabs: readonly KeptTab[]): Promise { + this.design = design; + if (!design) { + this.key = ''; + await this.unload(Object.values(this.scripts)); + this.scripts = {}; + await Promise.all(tabs.map((tab) => this.run(tab, [overlayCall('remove')]).then(() => this.fit(tab, null)))); + return; + } + const style = designStyleCall(design, false); + if (this.key === design.key) { + await this.unload([this.scripts.style]); + this.scripts.style = await this.preload(style); + await Promise.all(tabs.map((tab) => this.run(tab, [style]))); + } else { + await this.unload(Object.values(this.scripts)); + const image = overlayCall('setImage', design.base64); + this.scripts = { code: await this.preload(OVERLAY_JS), style: await this.preload(style), image: await this.preload(image) }; + this.key = design.key; + await Promise.all(tabs.map((tab) => this.run(tab, [OVERLAY_JS, style, image]))); + } + await Promise.all(tabs.map((tab) => this.fit(tab, design))); + } + + async found(tab: KeptTab): Promise { + // New documents get the preload scripts; only the width is the tab's own. + if (this.design) await this.fit(tab, this.design); + } + + hidden(tab: KeptTab, task: () => Promise): Promise { + return withDesignAside(() => this.design, (design, aside) => this.lay(tab, design, aside), task); + } + + gone(): void { + // Preload scripts are the browser's, not a tab's: nothing is kept per tab. + } + + /** Styles a tab's design as it is, or hidden with the page at its own width (`aside`, for a capture). */ + private async lay(tab: KeptTab, design: PageDesign, aside: boolean): Promise { + await this.run(tab, [designStyleCall(design, aside)]); + await this.fit(tab, aside ? null : design); + } + + /** Runs each expression in turn in a tab's document, in the overlay's sandbox. */ + private async run(tab: KeptTab, expressions: string[]): Promise { + for (const expression of expressions) await evaluateIn(this.connection, tab.info.id, expression, true, OVERLAY_WORLD); + } + + /** The tab's page at the design's width (its window's height kept) while that is on, else at its own. */ + private async fit(tab: KeptTab, design: PageDesign | null): Promise { + const context = tab.info.id; + const height = design?.settings.fitWidth ? Number(await evaluateIn(this.connection, context, HEIGHT_EXPRESSION)) : 0; + const viewport = design?.settings.fitWidth ? { width: design.width, height: height || design.height } : null; + await this.connection.send(BIDI.browsingContext.setViewport, { context, viewport }).catch(() => undefined); + } + + private async preload(expression: string): Promise { + const { script } = await this.connection.send<{ script: string }>(BIDI.script.addPreloadScript, { functionDeclaration: `() => { ${expression}; }`, sandbox: OVERLAY_WORLD }); + return script; + } + + private async unload(scripts: Array): Promise { + await Promise.all(scripts.filter((script) => script !== undefined).map((script) => this.connection.send(BIDI.script.removePreloadScript, { script }).catch(() => undefined))); + } +} diff --git a/src/main/browsers/driven/firefox/captureContext.ts b/src/main/browsers/driven/firefox/captureContext.ts new file mode 100644 index 00000000..f3f96961 --- /dev/null +++ b/src/main/browsers/driven/firefox/captureContext.ts @@ -0,0 +1,22 @@ +import type { CaptureArea, Rect } from '../../../../shared/types'; +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { captureInParts, capturedImageOf, type CapturedImage } from '../../../shots/capture'; +import { METRICS_EXPRESSION } from '../constants'; +import { pageMetricsOf } from '../pageMetricsOf'; +import { CLIP_BOX, SCREENSHOT_ORIGIN } from './constants'; +import { evaluateIn } from './evaluateIn'; + +/** + * Captures a Firefox tab as a PNG in device pixels: what its viewport shows, or its whole document (down to the + * height the app captures at most, in parts past a texture's side). Its scale is the image's pixels per CSS pixel; + * its viewport, the window's size. + */ +export async function captureContext(connection: BidiConnection, context: string, area: Exclude): Promise { + const metrics = pageMetricsOf(await evaluateIn(connection, context, METRICS_EXPRESSION)); + const clip = area === 'page' ? metrics.page : null; + const shoot = async (part: Rect | null) => { + const params = { context, origin: SCREENSHOT_ORIGIN[area], ...(part ? { clip: { type: CLIP_BOX, ...part } } : {}) }; + return Buffer.from((await connection.send<{ data: string }>(BIDI.browsingContext.captureScreenshot, params)).data, 'base64'); + }; + return capturedImageOf(clip ? await captureInParts(clip, metrics.window.ratio, shoot) : await shoot(null), clip?.width ?? null, metrics.window); +} diff --git a/src/main/browsers/driven/firefox/captureContextAt.ts b/src/main/browsers/driven/firefox/captureContextAt.ts new file mode 100644 index 00000000..b825a4ba --- /dev/null +++ b/src/main/browsers/driven/firefox/captureContextAt.ts @@ -0,0 +1,23 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import { LOADED_EXPRESSION, SETTLE_EXPRESSION } from '../../../shots/constants'; +import { LOAD_TIMEOUT_MS } from '../constants'; +import { captureContext } from './captureContext'; +import { evaluateIn } from './evaluateIn'; + +/** + * Captures a Firefox tab's whole page laid out in `viewport` (size and density), once it has loaded and been quiet a + * moment; the tab gets its own viewport back afterwards. + */ +export async function captureContextAt(connection: BidiConnection, context: string, { width, height, scale }: Viewport): Promise { + // A page still loading after that is captured as it is. + await withTimeout(evaluateIn(connection, context, LOADED_EXPRESSION, true), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); + await connection.send(BIDI.browsingContext.setViewport, { context, viewport: { width, height }, devicePixelRatio: scale }); + try { + await evaluateIn(connection, context, SETTLE_EXPRESSION, true); + return await captureContext(connection, context, 'page'); + } finally { + await connection.send(BIDI.browsingContext.setViewport, { context, viewport: null, devicePixelRatio: null }).catch(() => undefined); + } +} diff --git a/src/main/browsers/driven/firefox/connectFirefox.ts b/src/main/browsers/driven/firefox/connectFirefox.ts new file mode 100644 index 00000000..1d82ecb6 --- /dev/null +++ b/src/main/browsers/driven/firefox/connectFirefox.ts @@ -0,0 +1,18 @@ +import { BIDI, BidiConnection } from '../../../engine/bidi'; +import type { FoundBrowser } from '../../types'; +import { profileDir } from '../profileDir'; +import { reachOrLaunch } from '../reachOrLaunch'; +import type { Driver, DriverDeps } from '../types'; +import { DrivenFirefox } from './DrivenFirefox'; +import { launchFirefox } from './launchFirefox'; +import { readBidiPort } from './readBidiPort'; + +/** Drives Firefox over WebDriver BiDi: the one still open with the app's profile for it, or one launched now. */ +export async function connectFirefox(browser: FoundBrowser, deps: DriverDeps): Promise { + const dir = profileDir(browser, deps.userData); + const connection = await reachOrLaunch(() => readBidiPort(dir), (address) => BidiConnection.open(address), () => launchFirefox(browser, dir)); + const { capabilities } = await connection.send<{ capabilities: { browserVersion?: string } }>(BIDI.session.new, { capabilities: {} }); + const driver = new DrivenFirefox(browser, capabilities.browserVersion ?? null, connection, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/firefox/constants.ts b/src/main/browsers/driven/firefox/constants.ts new file mode 100644 index 00000000..70c284a1 --- /dev/null +++ b/src/main/browsers/driven/firefox/constants.ts @@ -0,0 +1,41 @@ +/** The file Firefox writes in its profile once its WebDriver BiDi server listens: `{ "ws_host", "ws_port" }`. */ +export const BIDI_PORT_FILE = 'WebDriverBiDiServer.json'; + +/** A BiDi session's path on that server. */ +export const SESSION_PATH = '/session'; + +/** + * What Firefox started to be driven is given: its profile (the folder follows the flag), its own instance, and a + * debugging port the system picks. + */ +export const PROFILE_FLAG = '--profile'; +export const FIREFOX_FLAGS = ['--no-remote', '--new-instance', '--remote-debugging-port=0'] as const; + +/** The preferences file of a Firefox profile, read at each start. */ +export const USER_PREFS_FILE = 'user.js'; + +/** A driven Firefox's preferences: no first-run pages, no default-browser question, no data reporting. */ +export const FIREFOX_PREFS: ReadonlyArray = [ + ['browser.shell.checkDefaultBrowser', false], + ['browser.startup.homepage_override.mstone', 'ignore'], + ['browser.aboutwelcome.enabled', false], + ['datareporting.policy.dataSubmissionEnabled', false], + ['toolkit.telemetry.reportingpolicy.firstRun', false], + ['browser.tabs.warnOnClose', false], +]; + +/** A tab's title, read in its page. */ +export const TITLE_EXPRESSION = 'document.title'; + +/** What a capture of each area is taken relative to, and the type of a box clipped out of it. */ +export const SCREENSHOT_ORIGIN = { viewport: 'viewport', page: 'document' } as const; +export const CLIP_BOX = 'box'; + +/** A new top-level context's type. */ +export const TAB_TYPE = 'tab'; + +/** How far `navigate` waits: not at all (opening a tab), or until the page has loaded (capturing it). */ +export const NAVIGATE_WAIT = { none: 'none', loaded: 'complete' } as const; + +/** A tab's window height, asked of its page to keep it while its width is a design's. */ +export const HEIGHT_EXPRESSION = 'String(innerHeight)'; diff --git a/src/main/browsers/driven/firefox/evaluateIn.ts b/src/main/browsers/driven/firefox/evaluateIn.ts new file mode 100644 index 00000000..e836b187 --- /dev/null +++ b/src/main/browsers/driven/firefox/evaluateIn.ts @@ -0,0 +1,18 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; + +/** What `script.evaluate` answers, as far as a string result goes. */ +interface Evaluated { + type: string; + result?: { type: string; value?: unknown }; +} + +/** + * Runs `expression` in a tab's page (in a sandbox of it when named; awaited when it is a promise); its value when that + * is a string, else null. + */ +export async function evaluateIn(connection: BidiConnection, context: string, expression: string, awaitPromise = false, sandbox?: string): Promise { + const target = sandbox ? { context, sandbox } : { context }; + const evaluated = await connection.send(BIDI.script.evaluate, { expression, target, awaitPromise }).catch(() => null); + const value = evaluated?.result?.value; + return typeof value === 'string' ? value : null; +} diff --git a/src/main/browsers/driven/firefox/launchFirefox.ts b/src/main/browsers/driven/firefox/launchFirefox.ts new file mode 100644 index 00000000..9bdabfd9 --- /dev/null +++ b/src/main/browsers/driven/firefox/launchFirefox.ts @@ -0,0 +1,14 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { FoundBrowser } from '../../types'; +import { launchDriven } from '../launchDriven'; +import { BIDI_PORT_FILE, FIREFOX_FLAGS, FIREFOX_PREFS, PROFILE_FLAG, USER_PREFS_FILE } from './constants'; +import { readBidiPort } from './readBidiPort'; + +/** Starts Firefox to be driven: its profile in `dir` (with the app's preferences), and a WebDriver BiDi port. */ +export async function launchFirefox(browser: FoundBrowser, dir: string): Promise { + await mkdir(dir, { recursive: true }); + const prefs = FIREFOX_PREFS.map(([name, value]) => `user_pref(${JSON.stringify(name)}, ${JSON.stringify(value)});`); + await writeFile(join(dir, USER_PREFS_FILE), `${prefs.join('\n')}\n`); + return launchDriven(browser, dir, { flags: [PROFILE_FLAG, dir, ...FIREFOX_FLAGS], portFile: BIDI_PORT_FILE, read: readBidiPort }); +} diff --git a/src/main/browsers/driven/firefox/readBidiPort.ts b/src/main/browsers/driven/firefox/readBidiPort.ts new file mode 100644 index 00000000..fe564aee --- /dev/null +++ b/src/main/browsers/driven/firefox/readBidiPort.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { BIDI_PORT_FILE, SESSION_PATH } from './constants'; + +/** The WebDriver BiDi address of the Firefox whose profile is `dir`, from the file it wrote there; null when there is none (yet). */ +export async function readBidiPort(dir: string): Promise { + const text = await readFile(join(dir, BIDI_PORT_FILE), 'utf8').catch(() => ''); + try { + const { ws_host: host, ws_port: port } = JSON.parse(text) as { ws_host?: unknown; ws_port?: unknown }; + return typeof host === 'string' && Number.isInteger(port) ? `ws://${host}:${String(port)}${SESSION_PATH}` : null; + } catch { + return null; + } +} diff --git a/src/main/browsers/driven/firefox/readFirefoxTabs.ts b/src/main/browsers/driven/firefox/readFirefoxTabs.ts new file mode 100644 index 00000000..9e79b6f0 --- /dev/null +++ b/src/main/browsers/driven/firefox/readFirefoxTabs.ts @@ -0,0 +1,16 @@ +import { BIDI, type BidiConnection } from '../../../engine/bidi'; +import { TITLE_EXPRESSION } from './constants'; +import { evaluateIn } from './evaluateIn'; +import type { TabRead } from '../types'; +import type { ContextInfo } from './types'; + +/** Each tab's address (from the tree) and title (asked of its page, which BiDi doesn't report). */ +export async function readFirefoxTabs(connection: BidiConnection, ids: string[]): Promise { + const { contexts } = await connection.send<{ contexts: ContextInfo[] }>(BIDI.browsingContext.getTree, { maxDepth: 0 }).catch(() => ({ contexts: [] as ContextInfo[] })); + const titles = await Promise.all(ids.map((id) => evaluateIn(connection, id, TITLE_EXPRESSION))); + return ids.map((id, i) => { + const url = contexts.find((c) => c.context === id)?.url; + const title = titles[i]; + return { id, ...(url === undefined ? {} : { url }), ...(title === null ? {} : { title }) }; + }); +} diff --git a/src/main/browsers/driven/firefox/types.ts b/src/main/browsers/driven/firefox/types.ts new file mode 100644 index 00000000..11e236ff --- /dev/null +++ b/src/main/browsers/driven/firefox/types.ts @@ -0,0 +1,6 @@ +/** What BiDi says of a browsing context: a tab's (no parent) or a frame's. */ +export interface ContextInfo { + context: string; + url: string; + parent?: string | null; +} diff --git a/src/main/browsers/driven/index.ts b/src/main/browsers/driven/index.ts new file mode 100644 index 00000000..eab4edec --- /dev/null +++ b/src/main/browsers/driven/index.ts @@ -0,0 +1 @@ +export { DrivenBrowsers } from './DrivenBrowsers'; diff --git a/src/main/browsers/driven/launchDriven.ts b/src/main/browsers/driven/launchDriven.ts new file mode 100644 index 00000000..1dd4ab51 --- /dev/null +++ b/src/main/browsers/driven/launchDriven.ts @@ -0,0 +1,26 @@ +import { mkdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { startBrowser } from '../startBrowser'; +import type { FoundBrowser } from '../types'; +import { PORT_WAIT, START_URL } from './constants'; +import { driveCommand } from './driveCommand'; +import type { LaunchSpec } from './types'; + +/** + * Starts a browser to be driven, its profile in `dir`, with `flags` (a debugging port among them) on a blank page; + * resolves with the address it writes in its profile, once written. A file left there by an earlier run names a port + * no longer listened on: it goes first. + */ +export async function launchDriven(browser: FoundBrowser, dir: string, { flags, portFile, read }: LaunchSpec): Promise { + await mkdir(dir, { recursive: true }); + await rm(join(dir, portFile), { force: true }); + const [program, ...args] = driveCommand(browser, flags, START_URL); + await startBrowser({ command: [program, ...args], urlAt: args.length + 1 }, []); + const deadline = Date.now() + PORT_WAIT.timeoutMs; + for (;;) { + const address = await read(dir); + if (address) return address; + if (Date.now() > deadline) throw new Error(`${browser.name} didn't open its debugging port`); + await new Promise((resolve) => setTimeout(resolve, PORT_WAIT.stepMs)); + } +} diff --git a/src/main/browsers/driven/pageMetricsOf.ts b/src/main/browsers/driven/pageMetricsOf.ts new file mode 100644 index 00000000..9b892469 --- /dev/null +++ b/src/main/browsers/driven/pageMetricsOf.ts @@ -0,0 +1,19 @@ +import { MAX_PAGE_SIDE } from '../../shots/constants'; +import type { PageMetrics } from './types'; + +/** + * What a tab's page said of itself (`METRICS_EXPRESSION`'s JSON): its window and density, and the box of its whole + * document to capture (down to the tallest page captured). A density of 1 and no sizes when it said nothing usable. + */ +export function pageMetricsOf(json: string | null | undefined): PageMetrics { + let answer: unknown; + try { + answer = JSON.parse(json ?? '[]'); + } catch { + answer = []; + } + const numbers = Array.isArray(answer) && answer.length === 5 && answer.every((n) => typeof n === 'number' && n > 0) ? (answer as number[]) : [0, 0, 1, 0, 0]; + const [width, height, ratio, documentWidth, documentHeight] = numbers; + const page = documentWidth ? { x: 0, y: 0, width: documentWidth, height: Math.min(documentHeight, Math.floor(MAX_PAGE_SIDE / ratio)) } : null; + return { window: { width, height, ratio }, page }; +} diff --git a/src/main/browsers/driven/playwright/DrivenPlaywright.ts b/src/main/browsers/driven/playwright/DrivenPlaywright.ts new file mode 100644 index 00000000..c47844ee --- /dev/null +++ b/src/main/browsers/driven/playwright/DrivenPlaywright.ts @@ -0,0 +1,140 @@ +import type { Page, Route } from 'playwright-core'; +import type { CaptureArea } from '../../../../shared/types'; +import { answerContext, type AnswerContext } from '../../../engine/answering'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import type { FoundBrowser } from '../../types'; +import { LOAD_TIMEOUT_MS } from '../constants'; +import { DrivenBase } from '../DrivenBase'; +import type { Driver, DriverDeps, TabRead } from '../types'; +import { answerRoute } from './answerRoute'; +import { capturePage } from './capturePage'; +import { capturePageAt } from './capturePageAt'; +import { ALL_URLS, TAB_ID_PREFIX, WAIT } from './constants'; +import { PlaywrightDesigns } from './PlaywrightDesigns'; +import type { PlaywrightSession, PlaywrightTab } from './types'; + +/** + * A browser launched through Playwright (WebKit's build): the workspace's overrides and rules served in each of its + * tabs through `route` (every request, its body included), its tabs listed with their address and title. It can't + * outlive the app's hold on it: letting go of it quits it, keeping its cookies and storage for next time. + */ +export class DrivenPlaywright extends DrivenBase implements Driver { + protected readonly designs: PlaywrightDesigns; + private readonly ctx: AnswerContext; + private readonly ids = new WeakMap(); + private count = 0; + // A request the answering failed on (its tab went away) is sent on, if it still can be. + private readonly answer = (route: Route) => answerRoute(this.ctx, route).catch(() => route.continue().catch(() => undefined)); + + constructor( + browser: FoundBrowser, + private readonly session: PlaywrightSession, + deps: DriverDeps, + ) { + super(browser, session.browser.version(), deps); + const { store, rules, settings } = deps.sources; + this.ctx = answerContext({ getOverrides: () => store.list(), getRules: () => rules.list(), getSettings: () => settings.get() }); + this.designs = new PlaywrightDesigns(session.context); + } + + async start(): Promise { + const { browser, context } = this.session; + const found = (page: Page) => void this.found(page); + const quit = () => this.closed(); + context.on('page', found); + browser.on('disconnected', quit); + this.disposers.push( + () => context.off('page', found), + () => browser.off('disconnected', quit), + ); + await context.route(ALL_URLS, this.answer); + for (const page of context.pages()) this.found(page); + } + + open(url: string): Promise { + return this.load(url, WAIT.started); + } + + async activate(tabId: string): Promise { + await this.tabs.get(tabId).page.bringToFront(); + } + + async refresh(): Promise { + this.ctx.overrides.clear(); + this.ctx.matchers.clear(); + } + + async applySettings(): Promise { + // Every request is routed, which keeps the browser's cache out of the way whatever the settings say. + } + + async reload(): Promise { + await Promise.all(this.tabs.webPages().map((t) => t.page.reload({ waitUntil: WAIT.started }).catch(() => undefined))); + } + + stop(): void { + this.dispose(); + this.tabs.clear(); + const { browser, context, stateFile } = this.session; + void context + .storageState({ path: stateFile }) + .catch(() => undefined) + .then(() => browser.close()) + .catch(() => undefined); + } + + protected async read(ids: string[]): Promise { + const pages = ids.flatMap((id) => (this.tabs.has(id) ? [{ id, page: this.tabs.get(id).page }] : [])); + return Promise.all(pages.map(async ({ id, page }) => ({ id, url: page.url(), title: await page.title().catch(() => undefined) }))); + } + + protected take(tab: PlaywrightTab, area: Exclude): Promise { + return capturePage(tab.page, area); + } + + protected openLoaded(url: string): Promise { + return withTimeout(this.load(url, WAIT.loaded), LOAD_TIMEOUT_MS, 'Loading the page'); + } + + protected takeAt(tab: PlaywrightTab, viewport: Viewport): Promise { + return capturePageAt(this.session, this.answer, tab.info.url, viewport); + } + + /** Loads an address in a blank tab, or else in a new one, and brings it to the front. */ + private async load(url: string, waitUntil: (typeof WAIT)[keyof typeof WAIT]): Promise { + const tab = this.tabs.blank() ?? this.found(await this.session.context.newPage()); + // Taken: another address opened before this one shows isn't loaded in it too. + tab.info = { ...tab.info, url }; + await tab.page.goto(url, { waitUntil }); + await tab.page.bringToFront(); + return tab; + } + + private found(page: Page): PlaywrightTab { + const known = this.ids.get(page); + if (known) return this.tabs.get(known); + const id = `${TAB_ID_PREFIX}${++this.count}`; + this.ids.set(page, id); + const tab = { info: { id, title: '', url: page.url() }, page }; + this.tabs.add(tab); + page.on('framenavigated', (frame) => frame === page.mainFrame() && this.tabs.update(id, { url: frame.url() }) && this.deps.changed()); + // A title a page sets isn't announced: it is read once the page has loaded. + page.on('load', () => void this.readTabs([id]).catch(() => undefined)); + page.on('close', () => { + const gone = this.tabs.remove(id); + if (gone) this.designs.gone(gone); + this.deps.changed(); + }); + this.deps.changed(); + void this.designs.found(tab); + return tab; + } + + /** The browser was quit: its tabs are gone with it. */ + private closed(): void { + this.dispose(); + this.tabs.clear(); + this.deps.closed(); + } +} diff --git a/src/main/browsers/driven/playwright/PlaywrightDesigns.ts b/src/main/browsers/driven/playwright/PlaywrightDesigns.ts new file mode 100644 index 00000000..d338e025 --- /dev/null +++ b/src/main/browsers/driven/playwright/PlaywrightDesigns.ts @@ -0,0 +1,84 @@ +import type { BrowserContext, Disposable } from 'playwright-core'; +import { designStyleCall, OVERLAY_JS, overlayCall, type PageDesign } from '../../../overlay'; +import type { TabDesigns } from '../types'; +import { withDesignAside } from '../withDesignAside'; +import type { PlaywrightTab } from './types'; + +/** A tab's window, read in its page. */ +const WINDOW_SIZE = '({ width: innerWidth, height: innerHeight })'; + +/** + * The app's design over the tabs of a browser driven through Playwright: the app page's overlay code, style and image + * as scripts of every new document (in the page's own world: Playwright has no other), run in each tab's document + * loaded now; the page laid out at the design's width (its viewport) while that is on, its own size back after. + */ +export class PlaywrightDesigns implements TabDesigns { + private design: PageDesign | null = null; + private scripts: Disposable[] = []; + private style: Disposable | null = null; + private key = ''; + /** Each tab's own window, while it is laid out at a design's width. */ + private readonly sizes = new Map(); + + constructor(private readonly context: BrowserContext) {} + + async set(design: PageDesign | null, tabs: readonly PlaywrightTab[]): Promise { + this.design = design; + await this.style?.dispose(); + this.style = null; + if (!design || this.key !== design.key) { + await Promise.all(this.scripts.splice(0).map((script) => script.dispose())); + this.key = ''; + await Promise.all(tabs.map((tab) => this.run(tab, [overlayCall('remove')]))); + } + if (design && !this.key) { + const image = overlayCall('setImage', design.base64); + this.scripts = await Promise.all([OVERLAY_JS, image].map((content) => this.context.addInitScript({ content }))); + this.key = design.key; + await Promise.all(tabs.map((tab) => this.run(tab, [OVERLAY_JS, image]))); + } + if (design) { + const style = designStyleCall(design, false); + this.style = await this.context.addInitScript({ content: style }); + await Promise.all(tabs.map((tab) => this.run(tab, [style]))); + } + await Promise.all(tabs.map((tab) => this.fit(tab, design))); + } + + async found(tab: PlaywrightTab): Promise { + // New documents get the scripts; only the width is the tab's own. + if (this.design) await this.fit(tab, this.design); + } + + hidden(tab: PlaywrightTab, task: () => Promise): Promise { + return withDesignAside(() => this.design, (design, aside) => this.lay(tab, design, aside), task); + } + + gone(tab: PlaywrightTab): void { + this.sizes.delete(tab); + } + + /** Styles a tab's design as it is, or hidden with the page at its own width (`aside`, for a capture). */ + private async lay(tab: PlaywrightTab, design: PageDesign, aside: boolean): Promise { + await tab.page.evaluate(designStyleCall(design, aside)).catch(() => undefined); + await this.fit(tab, aside ? null : design); + } + + /** Runs each expression in turn in a tab's document (one that is going away is left). */ + private async run({ page }: PlaywrightTab, expressions: string[]): Promise { + for (const expression of expressions) await page.evaluate(expression).catch(() => undefined); + } + + /** The tab's page at the design's width (its window's height kept) while that is on, else at its own size. */ + private async fit(tab: PlaywrightTab, design: PageDesign | null): Promise { + const own = this.sizes.get(tab); + if (design?.settings.fitWidth) { + const size = own ?? (await tab.page.evaluate<{ width: number; height: number }>(WINDOW_SIZE)); + this.sizes.set(tab, size); + await tab.page.setViewportSize({ width: design.width, height: size.height }).catch(() => undefined); + } else if (own) { + this.sizes.delete(tab); + await tab.page.setViewportSize(own).catch(() => undefined); + } + } +} diff --git a/src/main/browsers/driven/playwright/answerRoute.ts b/src/main/browsers/driven/playwright/answerRoute.ts new file mode 100644 index 00000000..3c1e6d7b --- /dev/null +++ b/src/main/browsers/driven/playwright/answerRoute.ts @@ -0,0 +1,35 @@ +import type { Route } from 'playwright-core'; +import { decideRequest, ruledHead, type AnswerContext, type RequestDecision } from '../../../engine/answering'; +import { holdFor } from '../../../engine/InterceptionEngine/holdFor'; +import { findResponseRules, type PausedRequest } from '../../../engine/rules'; +import { BLOCKED, OTHER_RESOURCE, RESOURCE_TYPES } from './constants'; +import { headersRecord } from './headersRecord'; + +/** How each decision is carried out through Playwright's route. */ +const CARRY_OUT: { [A in RequestDecision['action']]: (ctx: AnswerContext, route: Route, request: PausedRequest, type: string, decision: Extract) => Promise } = { + fail: (_ctx, route) => route.abort(BLOCKED), + answer: async (_ctx, route, _request, _type, { head, body, delayMs }) => { + await holdFor(delayMs); + await route.fulfill({ status: head.status, headers: headersRecord(head.headers), body }); + }, + // Sent on; fetched here first only when a response rule takes it, to change its head. + continue: async (ctx, route, request, type) => { + if (!findResponseRules(ctx.sources.getRules(), request.url, type, ctx.matchers).length) return route.continue(); + const response = await route.fetch(); + const head = ruledHead(ctx, request, type, { status: response.status(), headers: response.headersArray() }); + await route.fulfill(head ? { response, status: head.status, headers: headersRecord(head.headers) } : { response }); + }, +}; + +/** + * Answers a request a browser driven through Playwright is about to send, as {@link decideRequest} decides (its body + * known, so an override naming a GraphQL operation answers), with the response rules applied to what is sent on. + */ +export async function answerRoute(ctx: AnswerContext, route: Route): Promise { + const sent = route.request(); + const request: PausedRequest = { url: sent.url(), method: sent.method(), headers: await sent.allHeaders(), body: sent.postData() ?? undefined }; + const type = RESOURCE_TYPES[sent.resourceType()] ?? OTHER_RESOURCE; + const decision = decideRequest(ctx, request, type); + const carryOut = CARRY_OUT[decision.action] as (ctx: AnswerContext, route: Route, request: PausedRequest, type: string, decision: RequestDecision) => Promise; + await carryOut(ctx, route, request, type, decision); +} diff --git a/src/main/browsers/driven/playwright/capturePage.ts b/src/main/browsers/driven/playwright/capturePage.ts new file mode 100644 index 00000000..3fec304b --- /dev/null +++ b/src/main/browsers/driven/playwright/capturePage.ts @@ -0,0 +1,16 @@ +import type { Page } from 'playwright-core'; +import type { CaptureArea } from '../../../../shared/types'; +import { captureInParts, capturedImageOf, type CapturedImage } from '../../../shots/capture'; +import { METRICS_EXPRESSION } from '../constants'; +import { pageMetricsOf } from '../pageMetricsOf'; + +/** + * Captures a page through Playwright as a PNG in device pixels: what its window shows, or its whole document (down to + * the height the app captures at most, in parts past a texture's side). + */ +export async function capturePage(page: Page, area: Exclude): Promise { + const metrics = pageMetricsOf(await page.evaluate(METRICS_EXPRESSION)); + const clip = area === 'page' ? metrics.page : null; + const bytes = clip ? await captureInParts(clip, metrics.window.ratio, (part) => page.screenshot({ clip: part, fullPage: true })) : await page.screenshot(); + return capturedImageOf(bytes, clip?.width ?? null, metrics.window); +} diff --git a/src/main/browsers/driven/playwright/capturePageAt.ts b/src/main/browsers/driven/playwright/capturePageAt.ts new file mode 100644 index 00000000..8db369bf --- /dev/null +++ b/src/main/browsers/driven/playwright/capturePageAt.ts @@ -0,0 +1,27 @@ +import type { Route } from 'playwright-core'; +import { withTimeout } from '../../../engine/PageInterception'; +import type { CapturedImage, Viewport } from '../../../shots/capture'; +import { LOADED_EXPRESSION, SETTLE_EXPRESSION } from '../../../shots/constants'; +import { LOAD_TIMEOUT_MS } from '../constants'; +import { capturePage } from './capturePage'; +import { ALL_URLS, WAIT } from './constants'; +import type { PlaywrightSession } from './types'; + +/** + * Captures the whole page at `url` laid out in `viewport` (size and density), once it has loaded and been quiet a + * moment: in a context made for it (a density is a context's in Playwright), answered the same way and holding the + * same cookies, closed afterwards. + */ +export async function capturePageAt({ browser, context }: PlaywrightSession, answer: (route: Route) => Promise, url: string, { width, height, scale }: Viewport): Promise { + const own = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: scale, storageState: await context.storageState() }); + try { + await own.route(ALL_URLS, answer); + const page = await own.newPage(); + // A page still loading after that is captured as it is. + await withTimeout(page.goto(url, { waitUntil: WAIT.loaded }).then(() => page.evaluate(LOADED_EXPRESSION)), LOAD_TIMEOUT_MS, 'Loading the page').catch(() => undefined); + await page.evaluate(SETTLE_EXPRESSION); + return await capturePage(page, 'page'); + } finally { + await own.close(); + } +} diff --git a/src/main/browsers/driven/playwright/connectPlaywright.ts b/src/main/browsers/driven/playwright/connectPlaywright.ts new file mode 100644 index 00000000..971a8395 --- /dev/null +++ b/src/main/browsers/driven/playwright/connectPlaywright.ts @@ -0,0 +1,31 @@ +import { access, mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { FoundBrowser } from '../../types'; +import { profileDir } from '../profileDir'; +import type { Driver, DriverDeps } from '../types'; +import { STATE_FILE } from './constants'; +import { DrivenPlaywright } from './DrivenPlaywright'; +import { launchError } from './launchError'; +import type { PlaywrightLaunch } from './types'; + +/** + * Launches a browser through Playwright with the cookies and storage it kept last time (in its profile folder), in a + * window at its own size, and starts driving it. + */ +export async function connectPlaywright({ type, executablePath, headless }: PlaywrightLaunch, browser: FoundBrowser, deps: DriverDeps): Promise { + const dir = profileDir(browser, deps.userData); + await mkdir(dir, { recursive: true }); + const stateFile = join(dir, STATE_FILE); + const launched = await type.launch({ executablePath, headless }).catch((err: unknown) => { + throw launchError(browser.name, err); + }); + const kept = await access(stateFile).then( + () => stateFile, + () => undefined, + ); + // A state file that can't be read is left for a fresh one. + const context = await launched.newContext({ viewport: null, storageState: kept }).catch(() => launched.newContext({ viewport: null })); + const driver = new DrivenPlaywright(browser, { browser: launched, context, stateFile }, deps); + await driver.start(); + return driver; +} diff --git a/src/main/browsers/driven/playwright/connectWebKit.ts b/src/main/browsers/driven/playwright/connectWebKit.ts new file mode 100644 index 00000000..f3f2fe48 --- /dev/null +++ b/src/main/browsers/driven/playwright/connectWebKit.ts @@ -0,0 +1,13 @@ +import { access } from 'node:fs/promises'; +import type { FoundBrowser } from '../../types'; +import type { Driver, DriverDeps } from '../types'; +import { connectPlaywright } from './connectPlaywright'; + +/** Drives the WebKit build the app downloaded (Playwright's) in a window of its own; it has to be downloaded first. */ +export async function connectWebKit(browser: FoundBrowser, deps: DriverDeps): Promise { + const program = browser.program; + const downloaded = program ? await access(program).then(() => true, () => false) : false; + if (!program || !downloaded) throw new Error('Download WebKit first'); + const { webkit } = await import('playwright-core'); + return connectPlaywright({ type: webkit, executablePath: program, headless: false }, browser, deps); +} diff --git a/src/main/browsers/driven/playwright/constants.ts b/src/main/browsers/driven/playwright/constants.ts new file mode 100644 index 00000000..d3f428d3 --- /dev/null +++ b/src/main/browsers/driven/playwright/constants.ts @@ -0,0 +1,46 @@ +/** + * The CDP resource type the engine's matching knows for each of Playwright's (`request.resourceType()`); anything else + * is `Other`. + */ +export const RESOURCE_TYPES: Readonly> = { + document: 'Document', + stylesheet: 'Stylesheet', + image: 'Image', + media: 'Media', + font: 'Font', + script: 'Script', + texttrack: 'TextTrack', + xhr: 'XHR', + fetch: 'Fetch', + eventsource: 'EventSource', + websocket: 'WebSocket', + manifest: 'Manifest', + ping: 'Ping', +}; +export const OTHER_RESOURCE = 'Other'; + +/** Every request goes through the app's answering. */ +export const ALL_URLS = '**/*'; + +/** Why a request a block rule takes fails, as the page sees it. */ +export const BLOCKED = 'blockedbyclient'; + +/** How far opening an address waits: until it is on its way (a tab opened), or loaded (a tab captured). */ +export const WAIT = { started: 'commit', loaded: 'load' } as const; + +/** A tab's id: the order it was found in, after this. */ +export const TAB_ID_PREFIX = 'tab-'; + +/** Where a browser launched through Playwright keeps its cookies and storage between runs, in its profile folder. */ +export const STATE_FILE = 'storage-state.json'; + +/** Headers a response is fulfilled with, one value per name: several of one name joined as a browser would. */ +export const HEADER_JOIN = { 'set-cookie': '\n', other: ', ' } as const; + +/** What a browser Playwright launched said on its way out (`[pid=…][err] …` lines of the error), and how many are told. */ +export const BROWSER_SAID = /^\[pid=\d+\]\[err\] (.+)$/; +export const LINES_TOLD = 3; + +/** What a Linux browser missing a system library says, and what to do about it for WebKit. */ +export const MISSING_LIBRARY = 'shared librar'; +export const INSTALL_DEPS_HINT = 'on Linux, `sudo npx playwright install-deps webkit` installs the libraries it needs'; diff --git a/src/main/browsers/driven/playwright/headersRecord.ts b/src/main/browsers/driven/playwright/headersRecord.ts new file mode 100644 index 00000000..472f7a78 --- /dev/null +++ b/src/main/browsers/driven/playwright/headersRecord.ts @@ -0,0 +1,13 @@ +import type { HeaderEntry } from '../../../engine/transform'; +import { HEADER_JOIN } from './constants'; + +/** Headers as Playwright fulfils a response with them: one value per name, several of one name joined. */ +export function headersRecord(headers: readonly HeaderEntry[]): Record { + const record: Record = {}; + for (const { name, value } of headers) { + const key = name.toLowerCase(); + const join = key === 'set-cookie' ? HEADER_JOIN['set-cookie'] : HEADER_JOIN.other; + record[key] = key in record ? `${record[key]}${join}${value}` : value; + } + return record; +} diff --git a/src/main/browsers/driven/playwright/index.ts b/src/main/browsers/driven/playwright/index.ts new file mode 100644 index 00000000..62919c61 --- /dev/null +++ b/src/main/browsers/driven/playwright/index.ts @@ -0,0 +1 @@ +export { connectWebKit } from './connectWebKit'; diff --git a/src/main/browsers/driven/playwright/launchError.ts b/src/main/browsers/driven/playwright/launchError.ts new file mode 100644 index 00000000..659d0776 --- /dev/null +++ b/src/main/browsers/driven/playwright/launchError.ts @@ -0,0 +1,13 @@ +import { stripVTControlCharacters } from 'node:util'; +import { BROWSER_SAID, INSTALL_DEPS_HINT, LINES_TOLD, MISSING_LIBRARY } from './constants'; + +/** + * Why a browser Playwright launched couldn't start, in a line: what it said on its way out (else Playwright's first + * line), and how to install a system library it misses. + */ +export function launchError(name: string, err: unknown): Error { + const text = stripVTControlCharacters(err instanceof Error ? err.message : String(err)); + const said = text.split('\n').flatMap((line) => BROWSER_SAID.exec(line.trim())?.slice(1) ?? []).slice(0, LINES_TOLD); + const why = (said.length ? said : text.split('\n').slice(0, 1)).join(' · '); + return new Error(`${name} couldn't start: ${why}${why.includes(MISSING_LIBRARY) ? ` (${INSTALL_DEPS_HINT})` : ''}`); +} diff --git a/src/main/browsers/driven/playwright/types.ts b/src/main/browsers/driven/playwright/types.ts new file mode 100644 index 00000000..b5efe401 --- /dev/null +++ b/src/main/browsers/driven/playwright/types.ts @@ -0,0 +1,21 @@ +import type { Browser, BrowserContext, BrowserType, Page } from 'playwright-core'; +import type { KeptTab } from '../types'; + +/** A tab of a browser driven through Playwright: its page. */ +export interface PlaywrightTab extends KeptTab { + page: Page; +} + +/** How a browser is launched through Playwright: its kind (WebKit), its program, and whether it shows a window. */ +export interface PlaywrightLaunch { + type: BrowserType; + executablePath: string; + headless: boolean; +} + +/** A browser launched through Playwright: the browser, its context (the tabs), and where the context's state is kept. */ +export interface PlaywrightSession { + browser: Browser; + context: BrowserContext; + stateFile: string; +} diff --git a/src/main/browsers/driven/profileDir.ts b/src/main/browsers/driven/profileDir.ts new file mode 100644 index 00000000..baef523d --- /dev/null +++ b/src/main/browsers/driven/profileDir.ts @@ -0,0 +1,19 @@ +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import type { FoundBrowser } from '../types'; +import { PROFILES_DIR, SANDBOX_DIRS, SANDBOXED_PROFILE } from './constants'; + +/** + * Where a driven browser keeps its profile: in the app's data folder, or, for a Snap or a Flatpak, in the folder its + * sandbox lets it write (and the app read). + */ +export function profileDir(browser: FoundBrowser, userData: string): string { + const program = browser.program ?? ''; + if (program.startsWith(SANDBOX_DIRS.snapBin)) return join(homedir(), SANDBOX_DIRS.snap, basename(program), SANDBOX_DIRS.snapCommon, SANDBOXED_PROFILE); + if (basename(program) === SANDBOX_DIRS.flatpak) { + const run = browser.command.indexOf(SANDBOX_DIRS.flatpakRun); + const appId = browser.command.slice(run + 1).find((arg) => !arg.startsWith('-')); + if (run >= 0 && appId) return join(homedir(), SANDBOX_DIRS.flatpakData, appId, SANDBOX_DIRS.flatpakDataSub, SANDBOXED_PROFILE); + } + return join(userData, PROFILES_DIR, browser.id.replace(/[^a-z0-9._-]+/gi, '_')); +} diff --git a/src/main/browsers/driven/reachOrLaunch.ts b/src/main/browsers/driven/reachOrLaunch.ts new file mode 100644 index 00000000..ffa9a81f --- /dev/null +++ b/src/main/browsers/driven/reachOrLaunch.ts @@ -0,0 +1,10 @@ +/** + * A connection to the browser driven with a profile: the one the app launched before, if it is still open (the app was + * restarted, or let go of it), else one launched now. A browser already open with that profile would take a second + * launch's address itself, and never open a debugging port for it. + */ +export async function reachOrLaunch(read: () => Promise, connect: (address: string) => Promise, launch: () => Promise): Promise { + const running = await read(); + const reached = running ? await connect(running).catch(() => null) : null; + return reached ?? connect(await launch()); +} diff --git a/src/main/browsers/driven/shotBrowser.ts b/src/main/browsers/driven/shotBrowser.ts new file mode 100644 index 00000000..5d4a2823 --- /dev/null +++ b/src/main/browsers/driven/shotBrowser.ts @@ -0,0 +1,8 @@ +import type { ShotBrowser } from '../../../shared/types'; +import type { Driver } from './types'; + +/** A driven browser, as the browser a capture was taken in. */ +export function shotBrowser(driver: Driver): ShotBrowser { + const { browserId, name, version } = driver.list(); + return { id: browserId, name, version }; +} diff --git a/src/main/browsers/driven/types.ts b/src/main/browsers/driven/types.ts new file mode 100644 index 00000000..fb8d26a8 --- /dev/null +++ b/src/main/browsers/driven/types.ts @@ -0,0 +1,112 @@ +import type { AppEvent, CaptureArea, DrivenBrowser, DrivenTab } from '../../../shared/types'; +import type { PageDesign } from '../../overlay'; +import type { CapturedImage, Viewport } from '../../shots/capture'; +import type { OverrideStore } from '../../store/OverrideStore'; +import type { RuleStore } from '../../store/RuleStore'; +import type { SettingsStore } from '../../store/SettingsStore'; +import type { FoundBrowser } from '../types'; + +/** What a driven browser's tabs are served: the active workspace's overrides and rules, and the settings. */ +export interface InterceptionSources { + store: OverrideStore; + rules: RuleStore; + settings: SettingsStore; +} + +/** A capture of a driven browser's tab, with the address it showed. */ +export interface TabCapture { + image: CapturedImage; + url: string; +} + +/** What a tab's page says of itself: its window (CSS pixels) and density, and its whole document's box, if any. */ +export interface PageMetrics { + window: { width: number; height: number; ratio: number }; + page: { x: number; y: number; width: number; height: number } | null; +} + +/** A tab's address and title as read again, each when it could be. */ +export interface TabRead { + id: string; + url?: string; + title?: string; +} + +/** A tab as a driven browser keeps it: what is listed of it, and whatever its driver needs besides. */ +export interface KeptTab { + info: DrivenTab; +} + +/** A browser the app drives, over whichever protocol its engine speaks (CDP for Chromium, WebDriver BiDi for Firefox). */ +export interface Driver { + readonly browser: FoundBrowser; + /** Attaches to its tabs, the ones open now and every one opened later. */ + start(): Promise; + list(): DrivenBrowser; + /** Reads each tab's title and address again. */ + readTabs(): Promise; + /** Opens an address in its blank tab, or a new one, and brings it to the front. */ + open(url: string): Promise; + activate(tabId: string): Promise; + capture(tabId: string, area: Exclude): Promise; + /** Captures the whole page at `url` (in the tab showing it, or one opened there) laid out in `viewport`. */ + captureAt(url: string, viewport: Viewport): Promise; + /** After overrides or rules changed. */ + refresh(): Promise; + /** After the settings changed. */ + applySettings(): Promise; + /** Reloads its tabs showing a website. */ + reload(): Promise; + /** Lays the design over every tab (null: takes it off), and over tabs opened later. */ + setDesign(design: PageDesign | null): Promise; + /** Stops serving the workspace's changes; the browser stays open. */ + stop(): void; +} + +/** How a driver lays the app's design over its tabs. */ +export interface TabDesigns { + /** Lays `design` over each of `tabs` (null: takes it off), and keeps it for tabs found later. */ + set(design: PageDesign | null, tabs: readonly T[]): Promise; + /** Lays the design over a tab found since (nothing while there is none). */ + found(tab: T): Promise; + /** Runs `task` (a capture) with the design off the tab and the page at its own width, then lays it back. */ + hidden(tab: T, task: () => Promise): Promise; + /** Forgets a tab that closed. */ + gone(tab: T): void; +} + +export interface DriverDeps { + /** How the driver lists itself: its key among the driven browsers, and its name. */ + listedAs: { id: string; name: string; everyday: boolean }; + /** The home folder, where everyday profiles are. */ + home: string; + sources: InterceptionSources; + /** The app's data folder, where driven browsers keep their profiles. */ + userData: string; + /** A tab opened, closed, or changed its address or title. */ + changed(): void; + /** The browser went away (it was quit). */ + closed(): void; +} + +/** Launches (or reaches again) a browser to drive, and starts driving it. */ +export type ConnectDriver = (browser: FoundBrowser, deps: DriverDeps) => Promise; + +/** How a browser is launched to be driven: its flags, and the file in its profile it writes its address in. */ +export interface LaunchSpec { + flags: string[]; + portFile: string; + /** The address from that file, once written. */ + read(dir: string): Promise; +} + +export interface DrivenBrowsersDeps { + /** Where the browsers are found. */ + registry: { get(id: string): Promise }; + sources: InterceptionSources; + /** The app's data folder, where driven browsers keep their profiles. */ + userData: string; + /** The home folder, where everyday profiles are (the user's by default). */ + home?: string; + send(event: AppEvent): void; +} diff --git a/src/main/browsers/driven/withDesignAside.ts b/src/main/browsers/driven/withDesignAside.ts new file mode 100644 index 00000000..ea08f7cf --- /dev/null +++ b/src/main/browsers/driven/withDesignAside.ts @@ -0,0 +1,17 @@ +import type { PageDesign } from '../../overlay'; + +/** + * Runs `task` (a capture) with the design laid aside (hidden, the page at its own width) when there is one, then lays + * back the one there is by then. + */ +export async function withDesignAside(current: () => PageDesign | null, lay: (design: PageDesign, aside: boolean) => Promise, task: () => Promise): Promise { + const design = current(); + if (!design) return task(); + await lay(design, true); + try { + return await task(); + } finally { + const now = current(); + if (now) await lay(now, false); + } +} diff --git a/src/main/browsers/engineOf.ts b/src/main/browsers/engineOf.ts new file mode 100644 index 00000000..70766967 --- /dev/null +++ b/src/main/browsers/engineOf.ts @@ -0,0 +1,10 @@ +import { basename } from 'node:path'; +import type { BrowserEngine } from '../../shared/types'; +import { ENGINE_PATTERNS } from './constants'; + +/** A browser's engine, from its names (launcher id, program path, app or registry name): the first pattern any of them matches. */ +export function engineOf(names: (string | null | undefined)[]): BrowserEngine { + const known = names.filter((name): name is string => !!name).map((name) => basename(name)); + for (const [pattern, engine] of ENGINE_PATTERNS) if (known.some((name) => pattern.test(name))) return engine; + return 'unknown'; +} diff --git a/src/main/browsers/everyday/constants.ts b/src/main/browsers/everyday/constants.ts new file mode 100644 index 00000000..316d43f0 --- /dev/null +++ b/src/main/browsers/everyday/constants.ts @@ -0,0 +1,21 @@ +/** A Firefox session file's header: its magic, then the JSON's length (32-bit, little-endian), then an LZ4 block. */ +export const MOZ_LZ4 = { magic: 'mozLz40\0', sizeAt: 8, dataAt: 12 } as const; + +/** An LZ4 sequence's token: the literal length in its high nibble, the match length (less its minimum) in its low one. */ +export const LZ4 = { lengthBits: 4, nibble: 15, more: 255, minMatch: 4, offsetBytes: 2 } as const; + +/** The session file Firefox keeps up to date as it runs, in its profile. */ +export const SESSION_FILE = ['sessionstore-backups', 'recovery.jsonlz4'] as const; + +/** Firefox's list of profiles, in its data folder. */ +export const PROFILES_INI = 'profiles.ini'; + +/** Where Firefox keeps its data on each system, from the home folder; Linux's Snap and Flatpak keep theirs apart. */ +export const FIREFOX_DATA_DIRS: Partial> = { + linux: ['.mozilla/firefox', 'snap/firefox/common/.mozilla/firefox', '.var/app/org.mozilla.firefox/.mozilla/firefox'], + darwin: ['Library/Application Support/Firefox'], + win32: ['AppData/Roaming/Mozilla/Firefox'], +}; + +/** profiles.ini: an install's default profile (`[Install…]` groups), and the profiles (`[Profile…]` groups). */ +export const PROFILES_KEYS = { installGroup: 'Install', profileGroup: 'Profile', installDefault: 'Default', path: 'Path', name: 'Name', relative: 'IsRelative', relativeYes: '1' } as const; diff --git a/src/main/browsers/everyday/index.ts b/src/main/browsers/everyday/index.ts new file mode 100644 index 00000000..d0b9215c --- /dev/null +++ b/src/main/browsers/everyday/index.ts @@ -0,0 +1,2 @@ +export { listEverydayTabs } from './listEverydayTabs'; +export { listsTabs } from './listsTabs'; diff --git a/src/main/browsers/everyday/listEverydayTabs.ts b/src/main/browsers/everyday/listEverydayTabs.ts new file mode 100644 index 00000000..ace1f5da --- /dev/null +++ b/src/main/browsers/everyday/listEverydayTabs.ts @@ -0,0 +1,15 @@ +import { homedir } from 'node:os'; +import type { EverydayBrowser } from '../../../shared/types'; +import { runProgram } from '../runProgram'; +import type { FoundBrowser } from '../types'; +import { listFirefoxTabs } from './listFirefoxTabs'; +import { listScriptableTabs, SCRIPTING_PLATFORM } from './scriptable'; + +/** + * The tabs open in your everyday browsers, read when asked and here only: Firefox's from its session files, and on + * macOS, those of the running browsers among `browsers` that scripting reaches (Safari, Chrome, Edge, Brave, Arc…). + */ +export async function listEverydayTabs(browsers: readonly FoundBrowser[], home = homedir(), platform: NodeJS.Platform = process.platform, run = runProgram): Promise { + const [firefox, scripted] = await Promise.all([listFirefoxTabs(home, platform), platform === SCRIPTING_PLATFORM ? listScriptableTabs(browsers, run) : []]); + return [...firefox, ...scripted]; +} diff --git a/src/main/browsers/everyday/listFirefoxTabs.ts b/src/main/browsers/everyday/listFirefoxTabs.ts new file mode 100644 index 00000000..9275f9d4 --- /dev/null +++ b/src/main/browsers/everyday/listFirefoxTabs.ts @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { EverydayBrowser } from '../../../shared/types'; +import { FIREFOX_DATA_DIRS, PROFILES_INI, SESSION_FILE } from './constants'; +import { profilesOf } from './profilesOf'; +import { readMozLz4 } from './readMozLz4'; +import { sessionTabsOf } from './sessionTabsOf'; + +/** The browser name everyday Firefox tabs are listed under. */ +const FIREFOX = 'Firefox'; + +/** + * The tabs open in your everyday Firefox, profile by profile, as its session file has them (it is kept up to date while + * Firefox runs, and holds the last session once it has quit). Profiles with none are left out. + */ +export async function listFirefoxTabs(home: string, platform: NodeJS.Platform): Promise { + const found: EverydayBrowser[] = []; + for (const dataDir of (FIREFOX_DATA_DIRS[platform] ?? []).map((dir) => join(home, dir))) { + const ini = await readFile(join(dataDir, PROFILES_INI), 'utf8').catch(() => ''); + for (const profile of profilesOf(dataDir, ini)) { + const tabs = sessionTabsOf((await readMozLz4(join(profile.dir, ...SESSION_FILE))) ?? ''); + if (tabs.length) found.push({ id: profile.dir, name: FIREFOX, profile: profile.name, tabs }); + } + } + return found; +} diff --git a/src/main/browsers/everyday/listsTabs.ts b/src/main/browsers/everyday/listsTabs.ts new file mode 100644 index 00000000..7d63e0aa --- /dev/null +++ b/src/main/browsers/everyday/listsTabs.ts @@ -0,0 +1,7 @@ +import type { FoundBrowser } from '../types'; +import { SCRIPTING_PLATFORM, scriptableApp } from './scriptable'; + +/** Whether a browser's everyday tabs can be listed: Firefox's from its session file, and on macOS those scripting reaches. */ +export function listsTabs(browser: FoundBrowser, platform: NodeJS.Platform = process.platform): boolean { + return browser.engine === 'gecko' || (platform === SCRIPTING_PLATFORM && scriptableApp(browser) !== null); +} diff --git a/src/main/browsers/everyday/lz4Block.ts b/src/main/browsers/everyday/lz4Block.ts new file mode 100644 index 00000000..431c2844 --- /dev/null +++ b/src/main/browsers/everyday/lz4Block.ts @@ -0,0 +1,36 @@ +import { LZ4 } from './constants'; + +/** + * Decompresses an LZ4 block into at most `size` bytes: sequences of literals copied as they are, each but the last + * followed by a match copied from `offset` bytes back (overlapping copies repeat, byte by byte). + */ +export function lz4Block(input: Uint8Array, size: number): Buffer { + const out = Buffer.alloc(size); + let at = 0; + let written = 0; + // A length of 15 goes on in the bytes after it, each added, until one isn't 255. + const length = (start: number): number => { + if (start !== LZ4.nibble) return start; + let total = start; + let byte: number; + do { + byte = input[at++]; + total += byte; + } while (byte === LZ4.more && at < input.length); + return total; + }; + while (at < input.length) { + const token = input[at++]; + const literals = length(token >> LZ4.lengthBits); + out.set(input.subarray(at, at + literals), written); + at += literals; + written += literals; + if (at >= input.length) break; + const offset = input[at] | (input[at + 1] << 8); + at += LZ4.offsetBytes; + const matched = length(token & LZ4.nibble) + LZ4.minMatch; + if (!offset || offset > written || written + matched > size) throw new Error('Not an LZ4 block'); + for (let i = 0; i < matched; i++, written++) out[written] = out[written - offset]; + } + return out.subarray(0, written); +} diff --git a/src/main/browsers/everyday/parseIniGroups.ts b/src/main/browsers/everyday/parseIniGroups.ts new file mode 100644 index 00000000..eb72143f --- /dev/null +++ b/src/main/browsers/everyday/parseIniGroups.ts @@ -0,0 +1,13 @@ +/** The groups of an INI file (`[Name]` then `key=value` lines), in order, with their keys; comments and blank lines skipped. */ +export function parseIniGroups(text: string): Array<{ name: string; keys: Record }> { + const groups: Array<{ name: string; keys: Record }> = []; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + const group = /^\[(.+)\]$/.exec(line); + if (group) groups.push({ name: group[1], keys: {} }); + const eq = line.indexOf('='); + if (!group && eq > 0 && groups.length) groups[groups.length - 1].keys[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); + } + return groups; +} diff --git a/src/main/browsers/everyday/profilesOf.ts b/src/main/browsers/everyday/profilesOf.ts new file mode 100644 index 00000000..90f44849 --- /dev/null +++ b/src/main/browsers/everyday/profilesOf.ts @@ -0,0 +1,20 @@ +import { isAbsolute, join } from 'node:path'; +import { PROFILES_KEYS } from './constants'; +import { parseIniGroups } from './parseIniGroups'; + +/** A Firefox profile: its name, and its folder. */ +export interface FirefoxProfile { + name: string; + dir: string; +} + +/** The profiles a `profiles.ini` in `dataDir` lists, each once: the installs' defaults first (the ones Firefox opens). */ +export function profilesOf(dataDir: string, ini: string): FirefoxProfile[] { + const groups = parseIniGroups(ini); + const at = (path: string, relative: boolean) => (relative && !isAbsolute(path) ? join(dataDir, path) : path); + const defaults = groups.filter((g) => g.name.startsWith(PROFILES_KEYS.installGroup) && g.keys[PROFILES_KEYS.installDefault]).map((g) => at(g.keys[PROFILES_KEYS.installDefault], true)); + const profiles = groups + .filter((g) => g.name.startsWith(PROFILES_KEYS.profileGroup) && g.keys[PROFILES_KEYS.path]) + .map((g) => ({ name: g.keys[PROFILES_KEYS.name] ?? g.keys[PROFILES_KEYS.path], dir: at(g.keys[PROFILES_KEYS.path], g.keys[PROFILES_KEYS.relative] === PROFILES_KEYS.relativeYes) })); + return profiles.sort((a, b) => Number(defaults.includes(b.dir)) - Number(defaults.includes(a.dir))); +} diff --git a/src/main/browsers/everyday/readMozLz4.ts b/src/main/browsers/everyday/readMozLz4.ts new file mode 100644 index 00000000..288c2b8a --- /dev/null +++ b/src/main/browsers/everyday/readMozLz4.ts @@ -0,0 +1,14 @@ +import { readFile } from 'node:fs/promises'; +import { MOZ_LZ4 } from './constants'; +import { lz4Block } from './lz4Block'; + +/** The text of a Firefox `.jsonlz4` file (its session, its search engines…); null when it isn't one, or can't be read. */ +export async function readMozLz4(path: string): Promise { + const bytes = await readFile(path).catch(() => null); + if (!bytes || bytes.length < MOZ_LZ4.dataAt || bytes.toString('latin1', 0, MOZ_LZ4.sizeAt) !== MOZ_LZ4.magic) return null; + try { + return lz4Block(bytes.subarray(MOZ_LZ4.dataAt), bytes.readUInt32LE(MOZ_LZ4.sizeAt)).toString('utf8'); + } catch { + return null; + } +} diff --git a/src/main/browsers/everyday/scriptable/constants.ts b/src/main/browsers/everyday/scriptable/constants.ts new file mode 100644 index 00000000..8afa37a3 --- /dev/null +++ b/src/main/browsers/everyday/scriptable/constants.ts @@ -0,0 +1,36 @@ +/** + * The macOS browsers whose tabs scripting reaches (JavaScript for Automation), by app name, with what their tabs call + * a title: Safari's `name`, Chromium's `title`. Firefox has no tabs to script (its session file is read instead). + */ +export const SCRIPTABLE_APPS: Readonly> = { + Safari: 'name', + 'Safari Technology Preview': 'name', + 'Google Chrome': 'title', + 'Google Chrome Beta': 'title', + 'Google Chrome Dev': 'title', + 'Google Chrome Canary': 'title', + Chromium: 'title', + 'Microsoft Edge': 'title', + 'Microsoft Edge Beta': 'title', + 'Microsoft Edge Dev': 'title', + 'Microsoft Edge Canary': 'title', + 'Brave Browser': 'title', + 'Brave Browser Beta': 'title', + 'Brave Browser Nightly': 'title', + Vivaldi: 'title', + Arc: 'title', +}; + +/** The system that has it, and an app bundle's extension. */ +export const SCRIPTING_PLATFORM = 'darwin'; +export const APP_BUNDLE = '.app'; + +/** Runs a script as JavaScript for Automation: `osascript -l JavaScript -e '], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-firefox-')); + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + // Headless, so the test needs no screen of its own for it. + writeFileSync(join(dir, 'data/applications/test-firefox.desktop'), `[Desktop Entry]\nType=Application\nName=Firefox\nExec=${firefox} --headless %u\nCategories=Network;WebBrowser;\n`); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/page.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/page.html` && !wc.isLoading()), origin)); + await win.evaluate((o) => (window as unknown as Api).consoleEditor.createOverride({ kind: 'Script', sourceUrl: `${o}/app.js`, content: "document.title = 'overridden';", originalHash: null }), origin); + }); + + afterAll(async () => { + // Firefox outlives the app by design: quit it first (Playwright waits for everything the app started to be gone). + if (dir) killMatching(join(dir, 'user-data/browsers')); + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('opens the page in Firefox with your changes, and lists its tab', async () => { + await win.getByTestId('browser-menu-button').click(); + const row = win.locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-with-changes').click(); + await expect.poll(async () => (await driven())[0]?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: BROWSER_ID, name: 'Firefox', version: expect.stringMatching(/^\d+\./) }); + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => win.getByTestId('driven-tab').innerText()).toContain('overridden'); + await win.keyboard.press('Escape'); + }); + + it('captures the page here and in Firefox at once, as a group', async () => { + await expect.poll(() => win.getByTestId('browser-menu').count()).toBe(0); + await win.getByTestId('shots-menu-button').click(); + await win.getByTestId('shots-capture').click(); + await win.getByRole('menuitem', { name: 'In every browser' }).click(); + await expect.poll(async () => (await shots()).filter((s) => s.group).length, { timeout: 40_000 }).toBe(2); + const [other, own] = (await shots()).filter((s) => s.group); + expect(own).toMatchObject({ area: 'page', browser: { id: 'app' } }); + expect(other).toMatchObject({ area: 'page', pageUrl: `${origin}/page.html`, group: own.group, browser: { id: BROWSER_ID, name: 'Firefox' }, viewport: own.viewport, scale: own.scale }); + }); +}); diff --git a/test/e2e/driven.e2e.test.ts b/test/e2e/driven.e2e.test.ts new file mode 100644 index 00000000..d36d6210 --- /dev/null +++ b/test/e2e/driven.e2e.test.ts @@ -0,0 +1,179 @@ +/** + * A Chromium browser driven with the workspace's changes, in the built app on Linux: a real Chromium installed as a + * launcher is offered "with your changes" in the browser menu; the page opens there served the workspace's override, + * its tab is listed under the browser and captured into the shots, the page is captured here and there at once and + * the two compared, and letting go of it takes it off the menu. Your own Chromium, remote debugging on for it, is + * offered too, and served your changes only in the tab the app opens. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium, _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import type { DrivenBrowser, Shot } from '../../src/shared/types'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +const chromiumPath = (() => { + try { + return existsSync(chromium.executablePath()) ? chromium.executablePath() : null; + } catch { + return null; + } +})(); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + +const EDITOR_URL = /\/renderer\/index\.html$/; +const BROWSER_ID = 'desktop:test-chromium.desktop'; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built || !chromiumPath || process.platform !== 'linux')('A browser with your changes', () => { + let server: Server; + let origin: string; + let dir: string; + let app: ElectronApplication; + let win: Page; + let everyday: ChildProcess | undefined; + + type Api = { consoleEditor: { listDriven(): Promise; listShots(): Promise; createOverride(input: object): Promise } }; + const driven = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listDriven()); + const shots = () => win.evaluate(() => (window as unknown as Api).consoleEditor.listShots()); + const menu = () => win.getByTestId('browser-menu'); + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-driven-')); + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + // Headless, so the test needs no screen of its own for it. + writeFileSync(join(dir, 'data/applications/test-chromium.desktop'), `[Desktop Entry]\nType=Application\nName=Test Chromium\nExec=${chromiumPath} --headless=new ${sandboxArgs.join(' ')} %u\nCategories=Network;WebBrowser;\n`); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, HOME: join(dir, 'home'), CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/page.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/page.html` && !wc.isLoading()), origin)); + await win.evaluate((o) => (window as unknown as Api).consoleEditor.createOverride({ kind: 'Script', sourceUrl: `${o}/app.js`, content: "document.title = 'overridden';", originalHash: null }), origin); + }); + + afterAll(async () => { + // The browser outlives the app by design: quit it through its own port (first: Playwright waits for everything + // the app started to be gone before it takes the app for closed). + const address = dir ? await readActivePort(join(dir, 'user-data/browsers/desktop_test-chromium.desktop')) : null; + const connection = address ? await CdpConnection.connect(address).catch(() => null) : null; + await connection?.send('Browser.close').catch(() => undefined); + connection?.close(); + everyday?.kill(); + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('opens the page in a Chromium browser with your changes, and lists its tab under it', async () => { + await win.getByTestId('browser-menu-button').click(); + const row = menu().locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-with-changes').click(); + // Served the override: the title its script sets. + await expect.poll(async () => (await driven())[0]?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: BROWSER_ID, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./) }); + + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => row.getByTestId('browser-driven').count()).toBe(1); + const tab = menu().getByTestId('driven-tab'); + await expect.poll(() => tab.innerText()).toContain('overridden'); + expect(await tab.innerText()).toContain(`${origin}/page.html`); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill('nothing like it'); + await menu().getByText('No tab matches.').waitFor(); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill(''); + }); + + it("captures its tab into the shots, with the browser's name", async () => { + await menu().getByTestId('driven-tab-capture').click(); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(1); + const [shot] = await shots(); + expect(shot).toMatchObject({ kind: 'capture', area: 'viewport', pageUrl: `${origin}/page.html`, browser: { id: BROWSER_ID, name: 'Test Chromium' } }); + }); + + it('captures the page here and in the browser at once, as a group, and compares them', async () => { + await win.keyboard.press('Escape'); + await expect.poll(() => menu().count()).toBe(0); + await win.getByTestId('shots-menu-button').click(); + await win.getByTestId('shots-capture').click(); + await win.getByRole('menuitem', { name: 'In every browser' }).click(); + await expect.poll(async () => (await shots()).filter((s) => s.group).length, { timeout: 30_000 }).toBe(2); + // Newest first: the app's capture was taken first. + const [other, own] = (await shots()).filter((s) => s.group); + expect(own).toMatchObject({ kind: 'capture', area: 'page', pageUrl: `${origin}/page.html`, browser: { id: 'app' } }); + expect(other).toMatchObject({ area: 'page', pageUrl: `${origin}/page.html`, group: own.group, browser: { id: BROWSER_ID }, viewport: own.viewport, scale: own.scale, width: own.width }); + + await win.getByRole('button', { name: 'Compare', exact: true }).click(); + await win.getByTestId('group-page').waitFor(); + await expect.poll(() => win.getByTestId('group-cell').count()).toBe(2); + expect(await win.getByTestId('group-cell').first().getAttribute('data-shot-id')).toBe(own.id); + await expect.poll(() => win.getByTestId('group-share').innerText(), { timeout: 20_000 }).toMatch(/% differ$/); + expect(await win.getByTestId('group-baseline').innerText()).toContain('This app'); + await win.getByRole('tab', { name: 'Differences' }).click(); + await expect.poll(() => win.getByTestId('group-cell').locator('canvas').count()).toBe(1); + }); + + it('lets go of it, taking it off the menu', async () => { + await win.getByTestId('browser-menu-button').click(); + await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium' }).click(); + await expect.poll(() => driven()).toEqual([]); + await expect.poll(() => menu().getByTestId('driven-browsers').count()).toBe(0); + await win.keyboard.press('Escape'); + await expect.poll(() => menu().count()).toBe(0); + }); + + it('uses your own Chromium, remote debugging on for it, with your changes: only in the tab it opens', async () => { + // Your everyday Chromium (its profile in the home folder), with a tab of your own. + const profile = join(dir, 'home/.config/chromium'); + mkdirSync(profile, { recursive: true }); + everyday = spawn(chromiumPath!, ['--headless=new', ...sandboxArgs, `--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', 'about:blank'], { stdio: 'ignore' }); + await waitFor(() => readActivePort(profile)); + await win.getByTestId('browser-menu-button').click(); + const row = menu().locator(`[data-testid="browser-row"][data-browser-id="${BROWSER_ID}"]`); + await row.getByTestId('browser-open-everyday').click(); + await expect.poll(async () => (await driven()).find((d) => d.everyday)?.tabs.map((t) => t.title), { timeout: 30_000 }).toEqual(['overridden']); + expect((await driven())[0]).toMatchObject({ id: `${BROWSER_ID}#everyday`, browserId: BROWSER_ID, name: 'Test Chromium · your profile' }); + await win.getByTestId('browser-menu-button').click(); + await menu().getByRole('button', { name: 'Stop serving your changes in Test Chromium · your profile' }).click(); + await expect.poll(() => driven()).toEqual([]); + // Still running, as it was. + expect(everyday.exitCode).toBeNull(); + }); +}); diff --git a/test/e2e/everydayTabs.e2e.test.ts b/test/e2e/everydayTabs.e2e.test.ts new file mode 100644 index 00000000..18222a5a --- /dev/null +++ b/test/e2e/everydayTabs.e2e.test.ts @@ -0,0 +1,86 @@ +/** + * Your everyday Firefox's tabs, in the built app on Linux: with Firefox installed, the browser menu offers its tabs; + * asked, it lists them from the session file of your Firefox profile (read only then), the search narrows them, and + * choosing one loads its address in the app. + */ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { mozLz4 } from '../helpers/mozLz4'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; +const EDITOR_URL = /\/renderer\/index\.html$/; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built || process.platform !== 'linux')('Your Firefox tabs', () => { + let server: Server; + let origin: string; + let dir: string; + let app: ElectronApplication; + let win: Page; + const menu = () => win.getByTestId('browser-menu'); + + beforeAll(async () => { + server = createServer((req, res) => void res.writeHead(200, { 'content-type': 'text/html' }).end(`${new URL(req.url ?? '/', 'http://x').pathname}`)); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-everyday-')); + // A Firefox launcher (never started here), and a Firefox profile with a session in the home folder. + mkdirSync(join(dir, 'data/applications'), { recursive: true }); + mkdirSync(join(dir, 'system'), { recursive: true }); + const program = join(dir, 'firefox'); + writeFileSync(program, '#!/bin/sh\n'); + chmodSync(program, 0o755); + writeFileSync(join(dir, 'data/applications/firefox.desktop'), `[Desktop Entry]\nType=Application\nName=Firefox\nExec=${program} %u\nCategories=Network;WebBrowser;\n`); + const firefox = join(dir, 'home/.mozilla/firefox'); + mkdirSync(join(firefox, 'Profiles/a.default-release/sessionstore-backups'), { recursive: true }); + writeFileSync(join(firefox, 'profiles.ini'), '[Profile0]\nName=default-release\nIsRelative=1\nPath=Profiles/a.default-release\n'); + const tab = (path: string, title: string) => ({ index: 1, entries: [{ url: `${origin}${path}`, title }] }); + writeFileSync(join(firefox, 'Profiles/a.default-release/sessionstore-backups/recovery.jsonlz4'), mozLz4({ windows: [{ tabs: [tab('/cart', 'Your cart'), tab('/account', 'Your account'), { index: 1, entries: [{ url: 'about:newtab' }] }] }] })); + + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + env: { ...process.env, HOME: join(dir, 'home'), CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system') } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true }); + }); + + it('lists them once asked, narrowed by the search, and loads one here', async () => { + await win.getByTestId('browser-menu-button').click(); + // Nothing is read before it is asked for. + expect(await menu().getByTestId('everyday-tabs').count()).toBe(0); + await menu().getByTestId('everyday-show').click(); + await expect.poll(() => menu().getByTestId('everyday-tab').allInnerTexts()).toEqual([`Your cart\n${origin}/cart`, `Your account\n${origin}/account`]); + expect(await menu().getByTestId('everyday-tabs').innerText()).toContain('Firefox · default-release'); + await menu().getByRole('textbox', { name: 'Search browsers' }).fill('account'); + await expect.poll(() => menu().getByTestId('everyday-tab').count()).toBe(1); + await menu().getByTestId('everyday-tab').click(); + await expect.poll(() => win.getByTestId('address-bar').inputValue(), { timeout: 15_000 }).toBe(`${origin}/account`); + }); +}); diff --git a/test/e2e/shots.e2e.test.ts b/test/e2e/shots.e2e.test.ts new file mode 100644 index 00000000..c1e356e1 --- /dev/null +++ b/test/e2e/shots.e2e.test.ts @@ -0,0 +1,220 @@ +/** + * Captures in the built app: the shots menu at the toolbar's end (capturing what the page shows and the whole page, + * searching, the kinds), a capture's page (its size, the pixel under the pointer, renaming it), capturing an element + * picked in a cross-site frame (its pixels are the element's), deleting, captures kept per workspace and across a + * restart, and the website's own window opening a capture in the editor. + */ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Shot } from '../../src/shared/types'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + +const EDITOR_URL = /\/renderer\/index\.html$/; +const PAGE_WINDOW_URL = /\/renderer\/index\.html#page-window$/; + +/** Polls until `fn` returns a truthy value (usable outside tests, unlike expect.poll). */ +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!built)('Captures', () => { + let server: Server; + let origin: string; + let userData: string; + let app: ElectronApplication; + let win: Page; + + async function launch(): Promise { + app = await electron.launch({ args: [...sandboxArgs, root], cwd: root, env: { ...process.env, CONSOLE_EDITOR_USER_DATA: userData } as Record }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + } + + /** The active workspace's shots, as main lists them. */ + const shots = () => win.evaluate(() => (window as unknown as { consoleEditor: { listShots(): Promise } }).consoleEditor.listShots()); + + /** The pixel at x, y of a shot's file, read in main. */ + const pixel = (id: string, x: number, y: number) => + app.evaluate( + ({ nativeImage }, [dir, id, x, y]) => { + const image = nativeImage.createFromPath(`${dir}/workspace/shots/${id}.png`); + const { width } = image.getSize(); + const bgra = image.toBitmap(); + const i = (y * width + x) * 4; + return [bgra[i + 2], bgra[i + 1], bgra[i]]; + }, + [userData, id, x, y] as const, + ); + + /** Mouse input into the page through its debugger, as Chromium's input goes (it reaches cross-site frames). */ + const mouse = (type: string, point: { x: number; y: number }, extra: Record = {}) => + app.evaluate( + ({ webContents }, [type, point, extra, origin]) => { + const page = webContents.getAllWebContents().find((wc) => wc.getURL().startsWith(`${origin}/shell.html`))!; + return page.debugger.sendCommand('Input.dispatchMouseEvent', { type, ...point, ...extra }); + }, + [type, point, extra, origin] as const, + ); + + const menu = () => win.getByTestId('shots-menu'); + /** Closes the shots menu, and waits for it to be gone (opened again while it fades out, it would keep its search). */ + async function closeMenu(): Promise { + await win.keyboard.press('Escape'); + await expect.poll(() => menu().count()).toBe(0); + } + async function capture(what: string): Promise { + await win.getByTestId('shots-menu-button').click(); + await win.getByTestId('shots-capture').click(); + await win.getByRole('menuitem', { name: what }).click(); + } + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const { port } = server.address() as AddressInfo; + const pages: Record = { + '/shell.html': `Shell
`, + '/frame.html': '
', + }; + if (!pages[path]) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/html' }).end(pages[path]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-e2e-shots-')); + await launch(); + const bar = win.getByTestId('address-bar'); + await bar.fill(`${origin}/shell.html`); + await bar.press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/shell.html` && !wc.isLoading()), origin)); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true }); + }); + + it('captures what the page shows and the whole page from the shots menu, newest first', async () => { + await win.getByTestId('shots-menu-button').click(); + await menu().getByText('No captures yet.', { exact: false }).waitFor(); + await closeMenu(); + + await capture('What the page shows'); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(1); + await capture('The whole page'); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(2); + const [whole, shown] = await shots(); + expect(whole).toMatchObject({ kind: 'capture', area: 'page', height: 2400, browser: { id: 'app', name: 'Chromium' }, pageUrl: `${origin}/shell.html` }); + expect(whole.name).toMatch(/^127\.0\.0\.1-\d+-shell\.html-\d+-full\.png$/); + expect(shown.area).toBe('viewport'); + expect(await pixel(whole.id, 5, 2300)).toEqual([0, 255, 0]); + + await win.getByTestId('shots-menu-button').click(); + await expect.poll(() => menu().getByTestId('shot-row').count()).toBe(2); + expect(await menu().getByTestId('shot-row').first().getAttribute('data-shot-id')).toBe(whole.id); + await menu().getByRole('textbox', { name: 'Search captures and designs' }).fill('full'); + await expect.poll(() => menu().getByTestId('shot-row').count()).toBe(1); + await menu().getByRole('tab', { name: 'Designs' }).click(); + await menu().getByText('Nothing matches.').waitFor(); + await closeMenu(); + }); + + it("opens a capture's page: its size, the pixel under the pointer, and renaming it", async () => { + const [whole] = await shots(); + await win.getByTestId('shots-menu-button').click(); + await menu().locator(`[data-shot-id="${whole.id}"]`).click(); + const page = win.getByTestId('shot-page'); + await page.waitFor(); + await expect.poll(() => win.getByTestId('shot-footer').innerText()).toContain(`${whole.width} × ${whole.height} px`); + + await win.getByRole('button', { name: '100%' }).click(); + const image = win.getByTestId('shot-viewer').locator('img'); + const box = (await image.boundingBox())!; + await win.mouse.move(box.x + 5, box.y + 5); + await expect.poll(() => win.getByTestId('shot-pixel').innerText()).toContain('#0000ff'); + + await win.getByTestId('shot-name').click(); + await win.getByTestId('shot-name-input').fill('checkout.png'); + await win.getByTestId('shot-name-input').press('Enter'); + await expect.poll(async () => (await shots())[0].name).toBe('checkout.png'); + await expect.poll(() => win.getByRole('tab', { name: /checkout\.png/ }).count()).toBe(1); + }); + + it('captures an element picked in a cross-site frame, with its own pixels', async () => { + // Where the red box is in the page: the frame's box plus the box's in it. + const target = await waitFor(() => + app.evaluate(async ({ webContents }, o) => { + const page = webContents.getAllWebContents().find((wc) => wc.getURL() === `${o}/shell.html`); + const frame = page?.mainFrame.frames.find((f) => f.url.endsWith('/frame.html')); + const box = (target: { executeJavaScript(code: string): Promise } | undefined, selector: string) => + target?.executeJavaScript(`(() => { const r = document.querySelector(${JSON.stringify(selector)})?.getBoundingClientRect(); return r && { x: r.x, y: r.y }; })()`) as Promise<{ x: number; y: number } | undefined>; + const [outer, inner] = await Promise.all([box(page, 'iframe'), box(frame, '#red')]); + return outer && inner ? { x: Math.round(outer.x + inner.x + 20), y: Math.round(outer.y + inner.y + 20) } : undefined; + }, origin), + ); + await capture('An element…'); + await expect.poll(() => win.getByTestId('pick-element').getAttribute('aria-pressed')).toBe('true'); + await mouse('mouseMoved', target); + await mouse('mousePressed', target, { button: 'left', clickCount: 1 }); + await mouse('mouseReleased', target, { button: 'left', clickCount: 1 }); + await expect.poll(async () => (await shots()).length, { timeout: 15_000 }).toBe(3); + const element = (await shots())[0]; + expect(element).toMatchObject({ area: 'element', width: 120 * element.scale, height: 80 * element.scale }); + expect(element.name).toMatch(/-element\.png$/); + expect(await pixel(element.id, 1, 1)).toEqual([255, 0, 0]); + expect(await pixel(element.id, element.width - 2, element.height - 2)).toEqual([255, 0, 0]); + }); + + it('deletes a capture from its page, closing it', async () => { + const [element] = await shots(); + await win.getByTestId('shots-menu-button').click(); + await menu().locator(`[data-shot-id="${element.id}"]`).click(); + await win.getByTestId('shot-delete').click(); + await win.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click(); + await expect.poll(async () => (await shots()).map((s) => s.id)).not.toContain(element.id); + await expect.poll(() => win.getByRole('tab', { name: new RegExp(element.name.replaceAll('.', '\\.')) }).count()).toBe(0); + }); + + it('keeps captures per workspace, and across a restart', async () => { + const tiles = () => win.getByTestId('workspace-tile'); + await win.getByTestId('workspace-new').click(); + await expect.poll(() => tiles().nth(1).getAttribute('aria-current')).toBe('true'); + await expect.poll(async () => (await shots()).length).toBe(0); + await tiles().first().click(); + await expect.poll(() => tiles().first().getAttribute('aria-current')).toBe('true'); + await expect.poll(async () => (await shots()).length).toBe(2); + + await app.close(); + await launch(); + await expect.poll(async () => (await shots()).map((s) => s.name)).toEqual(['checkout.png', expect.stringMatching(/\.png$/)]); + }); + + it("opens a capture in the editor from the website's own window", async () => { + await win.getByRole('region', { name: 'Website preview' }).getByRole('button', { name: 'Open in its own window' }).click(); + const own = await waitFor(() => app.windows().find((p) => PAGE_WINDOW_URL.test(p.url()) && !p.isClosed())); + await own.waitForSelector('body[data-ready]'); + await own.getByTestId('shots-menu-button').click(); + await expect.poll(() => own.getByTestId('shot-row').count()).toBe(2); + // No deleting there: it would ask, and that window has no dialogs. + await own.getByTestId('shot-row').first().click(); + await expect.poll(() => win.getByTestId('shot-page').count(), { timeout: 10_000 }).toBe(1); + await expect.poll(() => win.getByTestId('shot-name').innerText()).toBe('checkout.png'); + }); +}); diff --git a/test/e2e/webkit.e2e.test.ts b/test/e2e/webkit.e2e.test.ts new file mode 100644 index 00000000..e567c5e8 --- /dev/null +++ b/test/e2e/webkit.e2e.test.ts @@ -0,0 +1,101 @@ +/** + * The WebKit build the app downloads, in the built app on Linux: listed in the browser menu on every system, to be + * downloaded first; downloaded from a stand-in for Playwright's host once agreed, its progress in its row; a build that + * can't start (the stand-in's) said so; and its download removed in Settings. + */ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { _electron as electron, type ElectronApplication, type Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { zipOf } from '../helpers/zipOf'; + +const root = resolve(__dirname, '../..'); +const built = existsSync(join(root, 'out/main/index.js')); +// Chromium's sandbox can't start as root (containers). +const sandboxArgs = process.getuid?.() === 0 ? ['--no-sandbox'] : []; +const EDITOR_URL = /\/renderer\/index\.html$/; +const WEBKIT_ID = 'playwright:webkit'; + +async function waitFor(fn: () => T | undefined | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out waiting for condition'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +/** A stand-in WebKit build: a program that can't start, and a library to make it a download worth watching. */ +const fakeBuild = () => + zipOf([ + { name: 'pw_run.sh', data: Buffer.from('#!/bin/sh\necho "not really WebKit" >&2\nexit 1\n'), mode: 0o100644 }, + { name: 'lib/libwebkit.so', data: Buffer.alloc(2_000_000, 7), compress: false }, + ]); + +describe.skipIf(!built || process.platform !== 'linux')('WebKit, downloaded by the app', () => { + let server: Server; + let dir: string; + let app: ElectronApplication; + let win: Page; + let origin: string; + const menu = () => win.getByTestId('browser-menu'); + const row = () => menu().locator(`[data-testid="browser-row"][data-browser-id="${WEBKIT_ID}"]`); + + beforeAll(async () => { + const zip = await fakeBuild(); + server = createServer((req, res) => { + if (req.url!.startsWith('/builds/webkit/')) return void res.writeHead(200, { 'content-type': 'application/zip', 'content-length': String(zip.length) }).end(zip); + res.writeHead(200, { 'content-type': 'text/html' }).end('Checked in WebKit'); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + dir = await mkdtemp(join(tmpdir(), 'console-editor-e2e-webkit-')); + app = await electron.launch({ + args: [...sandboxArgs, root], + cwd: root, + // No browsers of the system's: only the WebKit build; Playwright's host is the stand-in. + env: { ...process.env, CONSOLE_EDITOR_USER_DATA: join(dir, 'user-data'), XDG_DATA_HOME: join(dir, 'data'), XDG_DATA_DIRS: join(dir, 'system'), PLAYWRIGHT_DOWNLOAD_HOST: origin } as Record, + }); + win = await waitFor(() => app.windows().find((p) => EDITOR_URL.test(p.url()))); + await win.waitForSelector('body[data-ready]'); + await win.getByTestId('address-bar').fill(`${origin}/`); + await win.getByTestId('address-bar').press('Enter'); + await waitFor(() => app.evaluate(({ webContents }, o) => webContents.getAllWebContents().some((wc) => wc.getURL() === `${o}/` && !wc.isLoading()), origin)); + }); + + afterAll(async () => { + await app?.close(); + await new Promise((r) => server?.close(r)); + await rm(dir, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('offers WebKit to download first, downloads it once agreed, and says why a build that can\'t start didn\'t', async () => { + await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => row().innerText()).toContain('Download'); + await row().getByRole('button', { name: /^WebKit/ }).click(); + const dialog = win.getByRole('alertdialog'); + await expect.poll(() => dialog.innerText()).toContain('Download WebKit?'); + await dialog.getByRole('button', { name: 'Download' }).click(); + await win.getByText('WebKit is downloaded').waitFor(); + // The stand-in isn't WebKit: it can't start, and the app says so. + await win.getByText('Could not open WebKit with your changes').waitFor({ timeout: 30_000 }); + await win.getByText("WebKit couldn't start: not really WebKit").waitFor(); + expect(existsSync(join(dir, 'user-data/browsers/playwright'))).toBe(true); + if (!(await menu().count())) await win.getByTestId('browser-menu-button').click(); + await expect.poll(() => row().innerText()).toMatch(/WebKit\s+\d+\.\d+/); + await win.keyboard.press('Escape'); + }); + + it('removes its download in Settings, to be downloaded again when next used', async () => { + await win.getByTestId('rail-settings').click(); + const setting = win.getByTestId('browser-setting').filter({ hasText: 'WebKit' }); + await expect.poll(() => setting.innerText()).toContain('downloaded by the app'); + await setting.getByTestId('browser-remove-download').click(); + await expect.poll(() => setting.innerText()).toContain('downloaded when first used'); + }); +}); diff --git a/test/fixtures/firefox/recovery.jsonlz4 b/test/fixtures/firefox/recovery.jsonlz4 new file mode 100644 index 00000000..7c9731dd Binary files /dev/null and b/test/fixtures/firefox/recovery.jsonlz4 differ diff --git a/test/helpers/decodePng.ts b/test/helpers/decodePng.ts new file mode 100644 index 00000000..331ff7b9 --- /dev/null +++ b/test/helpers/decodePng.ts @@ -0,0 +1,62 @@ +/** + * Decodes an 8-bit, non-interlaced RGB or RGBA PNG (what Chromium's captures are) into RGBA pixels, so tests can + * look at what a capture shows. + */ +import { inflateSync } from 'node:zlib'; + +export interface DecodedPng { + width: number; + height: number; + /** The pixel at x, y as [r, g, b, a]. */ + at(x: number, y: number): [number, number, number, number]; +} + +const COLOR_TYPE_CHANNELS: Record = { 2: 3, 6: 4 }; + +export function decodePng(bytes: Buffer): DecodedPng { + let offset = 8; + let width = 0; + let height = 0; + let channels = 0; + const data: Buffer[] = []; + while (offset < bytes.length) { + const length = bytes.readUInt32BE(offset); + const type = bytes.toString('latin1', offset + 4, offset + 8); + const body = bytes.subarray(offset + 8, offset + 8 + length); + if (type === 'IHDR') { + width = body.readUInt32BE(0); + height = body.readUInt32BE(4); + if (body[8] !== 8 || body[12] !== 0) throw new Error('Only 8-bit, non-interlaced PNGs'); + channels = COLOR_TYPE_CHANNELS[body[9]]; + if (!channels) throw new Error(`PNG color type ${body[9]} isn't handled`); + } else if (type === 'IDAT') data.push(body); + offset += 12 + length; + } + const raw = inflateSync(Buffer.concat(data)); + const stride = width * channels; + const out = Buffer.alloc(height * stride); + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + for (let x = 0; x < stride; x++) { + const value = raw[y * (stride + 1) + 1 + x]; + const left = x >= channels ? out[y * stride + x - channels] : 0; + const up = y > 0 ? out[(y - 1) * stride + x] : 0; + const upLeft = x >= channels && y > 0 ? out[(y - 1) * stride + x - channels] : 0; + const paeth = () => { + const p = left + up - upLeft; + const [pa, pb, pc] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)]; + return pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + }; + const predictors = [0, left, up, (left + up) >> 1, paeth()]; + out[y * stride + x] = (value + predictors[filter]) & 0xff; + } + } + return { + width, + height, + at(x, y) { + const i = y * stride + x * channels; + return [out[i], out[i + 1], out[i + 2], channels === 4 ? out[i + 3] : 255]; + }, + }; +} diff --git a/test/helpers/encodePng.ts b/test/helpers/encodePng.ts new file mode 100644 index 00000000..a7abe697 --- /dev/null +++ b/test/helpers/encodePng.ts @@ -0,0 +1,38 @@ +/** Encodes RGB pixels as a PNG (8-bit, no filtering), for tests that need real image files. */ +import { deflateSync } from 'node:zlib'; + +/** CRC-32 of PNG chunks. */ +const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + return c >>> 0; +}); + +function crc(bytes: Buffer): number { + let c = 0xffffffff; + for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Buffer): Buffer { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); + const sum = Buffer.alloc(4); + sum.writeUInt32BE(crc(body)); + return Buffer.concat([length, body, sum]); +} + +/** A PNG of `width` × `height`, each pixel the colour `color(x, y)` gives. */ +export function encodePng(width: number, height: number, color: (x: number, y: number) => [number, number, number]): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + const raw = Buffer.alloc((width * 3 + 1) * height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) raw.set(color(x, y), y * (width * 3 + 1) + 1 + x * 3); + } + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]); +} diff --git a/test/helpers/killMatching.ts b/test/helpers/killMatching.ts new file mode 100644 index 00000000..12a47a24 --- /dev/null +++ b/test/helpers/killMatching.ts @@ -0,0 +1,19 @@ +import { readdirSync, readFileSync } from 'node:fs'; + +/** Stops every process whose command line holds `fragment` (Linux: read from /proc), this one aside. */ +export function killMatching(fragment: string): void { + for (const pid of readdirSync('/proc').filter((name) => /^\d+$/.test(name) && Number(name) !== process.pid)) { + let command = ''; + try { + command = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + } catch { + continue; + } + if (!command.includes(fragment)) continue; + try { + process.kill(Number(pid)); + } catch { + // Gone already. + } + } +} diff --git a/test/helpers/mozLz4.ts b/test/helpers/mozLz4.ts new file mode 100644 index 00000000..091ce57c --- /dev/null +++ b/test/helpers/mozLz4.ts @@ -0,0 +1,18 @@ +/** An LZ4 block of literals only (no matches): valid, if not small. */ +function lz4Literals(data: Buffer): Buffer { + const head = [Math.min(data.length, 15) << 4]; + if (data.length >= 15) { + let rest = data.length - 15; + for (; rest >= 255; rest -= 255) head.push(255); + head.push(rest); + } + return Buffer.concat([Buffer.from(head), data]); +} + +/** A Firefox `.jsonlz4` file of `value` (its session file's format). */ +export function mozLz4(value: unknown): Buffer { + const json = Buffer.from(JSON.stringify(value)); + const size = Buffer.alloc(4); + size.writeUInt32LE(json.length); + return Buffer.concat([Buffer.from('mozLz40\0', 'latin1'), size, lz4Literals(json)]); +} diff --git a/test/helpers/playwrightUtils.d.ts b/test/helpers/playwrightUtils.d.ts new file mode 100644 index 00000000..397ebc5e --- /dev/null +++ b/test/helpers/playwrightUtils.d.ts @@ -0,0 +1,10 @@ +/** What the tests use of playwright-core's bundled libraries, which it publishes untyped: yazl, to write zips. */ +declare module 'playwright-core/lib/utilsBundle' { + export const yazl: { + ZipFile: new () => { + addBuffer(data: Buffer, name: string, options?: { mode?: number; compress?: boolean }): void; + end(): void; + outputStream: NodeJS.ReadableStream; + }; + }; +} diff --git a/test/helpers/zipOf.ts b/test/helpers/zipOf.ts new file mode 100644 index 00000000..6698e28c --- /dev/null +++ b/test/helpers/zipOf.ts @@ -0,0 +1,11 @@ +import { yazl } from 'playwright-core/lib/utilsBundle'; + +/** A zip of the files given (with a Unix mode when one is), as a stand-in for a browser build's download. */ +export async function zipOf(files: Array<{ name: string; data: Buffer; mode?: number; compress?: boolean }>): Promise { + const zip = new yazl.ZipFile(); + for (const { name, data, mode, compress } of files) zip.addBuffer(data, name, { ...(mode === undefined ? {} : { mode }), ...(compress === undefined ? {} : { compress }) }); + zip.end(); + const chunks: Buffer[] = []; + for await (const chunk of zip.outputStream) chunks.push(chunk as Buffer); + return Buffer.concat(chunks); +} diff --git a/test/integration/driven.chromium.test.ts b/test/integration/driven.chromium.test.ts new file mode 100644 index 00000000..20cac5db --- /dev/null +++ b/test/integration/driven.chromium.test.ts @@ -0,0 +1,183 @@ +/** + * A Chromium browser driven with the workspace's changes: launched with a profile of the app's own and a debugging + * port, every tab served the overrides (a new tab from its first request), changes served and the tabs reloaded, a tab + * captured (and at a viewport given, as in every browser at once), the browser let go of (it stays open) and reached again rather than launched twice, and forgotten once + * it is quit. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DrivenBrowsers } from '../../src/main/browsers'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import type { FoundBrowser } from '../../src/main/browsers'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Settings } from '../../src/shared/types'; +import { chromiumAvailable } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!chromiumAvailable)('a Chromium browser driven with your changes', () => { + let server: Server; + let origin: string; + let userData: string; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS, autoReloadOnSave: true }; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => [] }, settings: { get: () => settings } } as unknown as InterceptionSources; + const browser: FoundBrowser = { + id: 'desktop:test-chromium.desktop', + name: 'Test Chromium', + engine: 'chromium', + // Headless here; the sandbox can't start as root (containers). + command: [chromium.executablePath(), '--headless=new', ...(process.getuid?.() === 0 ? ['--no-sandbox'] : [])], + urlAt: process.getuid?.() === 0 ? 3 : 2, + iconFile: null, + app: null, + program: chromium.executablePath(), + added: false, + }; + const profile = () => join(userData, 'browsers', 'desktop_test-chromium.desktop'); + const tabs = (): DrivenTab[] => driven.list()[0]?.tabs ?? []; + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/design.html': ['text/html', `-`], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-driven-')); + const now = Date.now(); + overrides.push({ id: 'o1', kind: 'Script', sourceUrl: `${origin}/app.js`, match: defaultMatcherFor(`${origin}/app.js`), content: "document.title = 'overridden';", enabled: true, originalHash: null, createdAt: now, updatedAt: now }); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + // The browser outlives the app by design: quit it through its own port. + const address = userData ? await readActivePort(profile()) : null; + const connection = address ? await CdpConnection.connect(address).catch(() => null) : null; + await connection?.send('Browser.close').catch(() => undefined); + connection?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('launches it with a profile of its own, and serves the overrides in the tab it opens', async () => { + await driven.open(browser.id, `${origin}/page.html`); + await waitFor(() => tabs().some((t) => t.url === `${origin}/page.html` && t.title === 'overridden')); + expect(driven.list()).toEqual([{ id: browser.id, browserId: browser.id, everyday: false, name: 'Test Chromium', version: expect.stringMatching(/^\d+\./), tabs: expect.any(Array) }]); + expect(existsSync(join(profile(), 'DevToolsActivePort'))).toBe(true); + expect(events.at(-1)).toMatchObject({ type: 'driven-browsers-changed', driven: [{ id: browser.id }] }); + }); + + it('opens another address in a new tab, served the overrides from its first request', async () => { + await driven.open(browser.id, `${origin}/page.html?second`); + await waitFor(() => tabs().some((t) => t.url.endsWith('?second') && t.title === 'overridden')); + expect(tabs()).toHaveLength(2); + }); + + it('serves overrides as they change, reloading the tabs when the settings say so', async () => { + overrides[0] = { ...overrides[0], content: "document.title = 'changed';", updatedAt: Date.now() }; + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(() => tabs().length === 2 && tabs().every((t) => t.title === 'changed')); + }); + + it("captures a tab, with its address and the browser's name and version", async () => { + const [tab] = tabs(); + const { image, url, browser: taken } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(url).toBe(tab.url); + expect(taken).toEqual({ id: browser.id, name: 'Test Chromium', version: driven.list()[0].version }); + expect(image.width).toBeGreaterThan(0); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + await expect(driven.capture(browser.id, tab.id, 'element')).rejects.toThrow('Invalid capture area'); + }); + + it('captures the whole page at an address in every driven browser, laid out in a viewport given (the app\'s)', async () => { + const taken = await driven.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + expect(taken).toHaveLength(1); + const [capture] = taken; + if ('reason' in capture) throw new Error(capture.reason); + expect(capture).toMatchObject({ url: `${origin}/page.html`, browser: { id: browser.id, name: 'Test Chromium' } }); + expect(capture.image).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + // The tab showing it was used: no other opened. + expect(tabs()).toHaveLength(2); + }); + + it('lets go of it (it stays open), and reaches it again rather than launching another', async () => { + const port = readFileSync(join(profile(), 'DevToolsActivePort'), 'utf8'); + driven.stop(browser.id); + expect(driven.list()).toEqual([]); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + + await driven.open(browser.id, `${origin}/page.html?third`); + expect(readFileSync(join(profile(), 'DevToolsActivePort'), 'utf8')).toBe(port); + await waitFor(() => tabs().some((t) => t.url.endsWith('?third') && t.title === 'changed')); + expect(tabs()).toHaveLength(3); + }); + + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + // What the page says of the design (and its width), and which document it is (a reload makes another). + const read = async () => ((await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? '').split(' '); + const title = async () => (await read()).slice(0, -1).join(' '); + const documentId = async () => (await read()).at(-1); + await driven.open(browser.id, `${origin}/design.html`); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driven.setDesign(design); + await waitFor(async () => (await title()) === 'design 0.5 300'); + await driven.setDesign({ ...design, settings: { ...settings, opacity: 1 } }); + await waitFor(async () => (await title()) === 'design 1 300'); + // Captured without it, at the window's own width. + const tab = (await driven.read())[0].tabs.find((t) => t.url === `${origin}/design.html`)!; + const { image } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await title()) === 'design 1 300'); + // A new document gets it too. + const before = await documentId(); + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await documentId()) !== before && (await title()) === 'design 1 300'); + await driven.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); + }); + + it('refuses a browser it can\'t drive (Safari), and an address not on the web', async () => { + const other = new DrivenBrowsers({ registry: { get: async () => ({ ...browser, name: 'Safari', engine: 'webkit' }) }, sources, userData, send: () => undefined }); + await expect(other.open(browser.id, `${origin}/page.html`)).rejects.toThrow("Safari can't be served your changes"); + await expect(driven.open(browser.id, 'file:///etc/passwd')).rejects.toThrow('Only http(s) pages'); + }); + + it('forgets it once it is quit', async () => { + const connection = await CdpConnection.connect((await readActivePort(profile()))!); + await connection.send('Browser.close').catch(() => undefined); + connection.close(); + await waitFor(() => driven.list().length === 0); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + }); +}); diff --git a/test/integration/driven.firefox.test.ts b/test/integration/driven.firefox.test.ts new file mode 100644 index 00000000..a46fc16c --- /dev/null +++ b/test/integration/driven.firefox.test.ts @@ -0,0 +1,186 @@ +/** + * Firefox driven with the workspace's changes over WebDriver BiDi: launched with a profile of the app's own, a script + * override served, a header rule and a block rule applied, a cross-origin request an override answers readable (its + * preflight answered), a GraphQL operation answered by the body sent, changes served and the tab reloaded, a tab captured (and at a viewport given, a page taller than a texture in parts), the browser let + * go of and reached again, and forgotten once it is quit. Runs where Firefox is found: `FIREFOX_PATH`, or `firefox` on + * the PATH. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DrivenBrowsers, type FoundBrowser } from '../../src/main/browsers'; +import { readBidiPort } from '../../src/main/browsers/driven/firefox/readBidiPort'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import { BidiConnection } from '../../src/main/engine/bidi'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type DrivenTab, type Override, type Rule, type Settings } from '../../src/shared/types'; +import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; +import { killMatching } from '../helpers/killMatching'; + +const firefox = (() => { + if (process.env.FIREFOX_PATH) return existsSync(process.env.FIREFOX_PATH) ? process.env.FIREFOX_PATH : null; + try { + return execFileSync('which', ['firefox'], { encoding: 'utf8' }).trim() || null; + } catch { + return null; + } +})(); + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 30_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 200)); + } +} + +describe.skipIf(!firefox)('Firefox driven with your changes', () => { + let server: Server; + let origin: string; + let other: string; + let userData: string; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const rules: Rule[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS, autoReloadOnSave: true }; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => rules }, settings: { get: () => settings } } as unknown as InterceptionSources; + const browser: FoundBrowser = { id: 'desktop:firefox.desktop', name: 'Firefox', engine: 'gecko', command: [firefox ?? 'firefox', '--headless'], urlAt: 2, iconFile: null, app: null, program: firefox, added: false }; + const profile = () => join(userData, 'browsers', 'desktop_firefox.desktop'); + const tabs = async (): Promise => (await driven.read())[0]?.tabs ?? []; + const now = Date.now(); + const override = (sourceUrl: string, kind: Override['kind'], content: string, extra: Partial = {}): Override => ({ id: `o${overrides.length}`, kind, sourceUrl, match: defaultMatcherFor(sourceUrl), content, enabled: true, originalHash: null, createdAt: now, updatedAt: now, ...extra }); + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + // A GraphQL endpoint on the other origin, readable there but answering no preflight: only the app does. + if (path === '/graphql') return void res.writeHead(req.method === 'OPTIONS' ? 404 : 200, { 'access-control-allow-origin': '*' }).end('upstream'); + const ask = `(name) => fetch('${other}/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ operationName: name, query: 'query ' + name + ' { a }' }) }).then((r) => r.text(), () => 'refused')`; + const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; + const files: Record = { + '/page.html': ['text/html', `upstream`], + '/design.html': ['text/html', `-`], + '/graphql.html': ['text/html', `-`], + // 40,000 rows, each its own colour: its position, red the low byte and green the high one. + '/tall.html': ['text/html', ''], + '/app.js': ['text/javascript', "window.appValue = 'upstream';"], + '/api': ['application/json', '{}'], + '/blocked.js': ['text/javascript', ''], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + origin = `http://127.0.0.1:${port}`; + // Another origin (a CORS request), whose server never answers `/data`: only the override does. + other = `http://localhost:${port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-firefox-')); + overrides.push(override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } }), override(`${other}/graphql`, 'Fetch', 'from-graphql', { request: { method: 'POST', operation: 'GetUser' }, response: { status: 200, delayMs: 0, headers: [], send: false, patch: false } })); + const rule = { enabled: true, resourceTypes: [], createdAt: now, updatedAt: now }; + rules.push({ ...rule, id: 'r1', action: 'headers', match: defaultMatcherFor(`${origin}/api`), headers: [{ operation: 'set', name: 'x-rule', value: 'yes' }] }, { ...rule, id: 'r2', action: 'block', match: defaultMatcherFor(`${origin}/blocked.js`) }); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + // Firefox outlives the app by design: quit it through its own port. + const address = userData ? await readBidiPort(profile()) : null; + const connection = address ? await BidiConnection.open(address).catch(() => null) : null; + await connection?.send('session.new', { capabilities: {} }).catch(() => undefined); + await connection?.send('browser.close').catch(() => undefined); + connection?.close(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('launches it with a profile of its own, serving the overrides and applying the rules', async () => { + await driven.open(browser.id, `${origin}/page.html`); + await waitFor(async () => (await tabs()).some((t) => t.title === 'overridden yes blocked from-override')); + expect(driven.list()).toEqual([{ id: browser.id, browserId: browser.id, everyday: false, name: 'Firefox', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html` })] }]); + expect(existsSync(join(profile(), 'WebDriverBiDiServer.json'))).toBe(true); + expect(readFileSync(join(profile(), 'user.js'), 'utf8')).toContain('browser.shell.checkDefaultBrowser'); + }); + + it('serves overrides as they change, reloading its tabs when the settings say so', async () => { + overrides[0] = { ...overrides[0], content: "window.appValue = 'changed';", updatedAt: Date.now() }; + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await tabs()).some((t) => t.title.startsWith('changed yes blocked'))); + }); + + it('captures a tab, and the whole page at a viewport given', async () => { + const [tab] = await tabs(); + const { image, browser: taken } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(taken).toMatchObject({ id: browser.id, name: 'Firefox' }); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + const [capture] = await driven.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + if ('reason' in capture) throw new Error(capture.reason); + expect(capture.image).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + expect(await tabs()).toHaveLength(1); + }); + + it('lets go of it (it stays open), and reaches it again rather than launching another', async () => { + const port = readFileSync(join(profile(), 'WebDriverBiDiServer.json'), 'utf8'); + driven.stop(browser.id); + expect(driven.list()).toEqual([]); + await driven.open(browser.id, `${origin}/page.html?again`); + expect(readFileSync(join(profile(), 'WebDriverBiDiServer.json'), 'utf8')).toBe(port); + await waitFor(async () => (await tabs()).some((t) => t.url.endsWith('?again') && t.title.startsWith('changed'))); + }); + + it('answers the GraphQL operation an override names, read off the body the page sends, and its preflight', async () => { + await driven.open(browser.id, `${origin}/graphql.html`); + await waitFor(async () => (await tabs()).some((t) => t.url === `${origin}/graphql.html` && t.title === 'from-graphql upstream')); + }); + + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + // What the page says of the design (and its width), and which document it is (a reload makes another). + const read = async () => ((await driven.read())[0]?.tabs.find((t) => t.url === `${origin}/design.html`)?.title ?? '').split(' '); + const title = async () => (await read()).slice(0, -1).join(' '); + const documentId = async () => (await read()).at(-1); + await driven.open(browser.id, `${origin}/design.html`); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driven.setDesign(design); + await waitFor(async () => (await title()) === 'design 0.5 300'); + await driven.setDesign({ ...design, settings: { ...settings, opacity: 1 } }); + await waitFor(async () => (await title()) === 'design 1 300'); + // Captured without it, at the window's own width. + const tab = (await driven.read())[0].tabs.find((t) => t.url === `${origin}/design.html`)!; + const { image } = await driven.capture(browser.id, tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await title()) === 'design 1 300'); + // A new document gets it too. + const before = await documentId(); + driven.onAppEvent({ type: 'overrides-changed', overrides: [] }); + await waitFor(async () => (await documentId()) !== before && (await title()) === 'design 1 300'); + await driven.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await title()) && !(await title()).endsWith(' 300')); + }); + + it('captures a page taller than a texture in parts, joined without a gap or a repeat, down to the tallest a canvas draws', async () => { + const [capture] = await driven.captureAt(`${origin}/tall.html`, { width: 60, height: 600, scale: 1 }); + if ('reason' in capture) throw new Error(capture.reason); + expect(capture.image).toMatchObject({ height: 32_767, scale: 1 }); + const png = decodePng(capture.image.bytes); + const wrong = Array.from({ length: png.height }, (_, y) => y).filter((y) => png.at(1, y)[0] + png.at(1, y)[1] * 256 !== y); + expect(wrong.slice(0, 5)).toEqual([]); + }); + + it('forgets it once it is quit', async () => { + killMatching(profile()); + await waitFor(() => driven.list().length === 0); + expect(events.at(-1)).toEqual({ type: 'driven-browsers-changed', driven: [] }); + }); +}); diff --git a/test/integration/everydayChrome.chromium.test.ts b/test/integration/everydayChrome.chromium.test.ts new file mode 100644 index 00000000..7137d07c --- /dev/null +++ b/test/integration/everydayChrome.chromium.test.ts @@ -0,0 +1,118 @@ +/** + * Your everyday Chromium browser, remote debugging turned on for it (Chrome 144's chrome://inspect/#remote-debugging): + * found through the address it writes in its everyday profile, listed as debuggable while it runs, driven without a + * launch, only in the tab the app opens (served the overrides), your own tab left alone, and let go of as it was. A + * Chromium started on a stand-in everyday profile with a debugging port plays the part. + */ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { BrowserRegistry, DrivenBrowsers, type FoundBrowser } from '../../src/main/browsers'; +import { readActivePort } from '../../src/main/browsers/driven/chromium/readActivePort'; +import { reachableEveryday } from '../../src/main/browsers/driven/everyday/reachableEveryday'; +import type { InterceptionSources } from '../../src/main/browsers/driven/types'; +import { CdpConnection } from '../../src/main/engine/websocketTransport'; +import { BrowserStore } from '../../src/main/store/BrowserStore'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type AppEvent, type Override } from '../../src/shared/types'; +import { chromiumAvailable } from '../helpers/chromium'; + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!chromiumAvailable)('your everyday Chromium browser, remote debugging on', () => { + let server: Server; + let origin: string; + let home: string; + let profile: string; + let everyday: ChildProcess; + let driven: DrivenBrowsers; + const events: AppEvent[] = []; + const overrides: Override[] = []; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => [] }, settings: { get: () => DEFAULT_SETTINGS } } as unknown as InterceptionSources; + const browser: FoundBrowser = { id: 'desktop:google-chrome.desktop', name: 'Google Chrome', engine: 'chromium', command: ['/usr/bin/google-chrome-stable'], urlAt: 1, iconFile: null, app: null, program: '/usr/bin/google-chrome-stable', added: false }; + /** The page targets the browser has, as its own debugging port lists them. */ + const pages = async () => { + const connection = await CdpConnection.connect((await readActivePort(profile))!); + const { targetInfos } = await connection.send<{ targetInfos: Array<{ type: string; url: string; attached: boolean }> }>('Target.getTargets'); + connection.close(); + return targetInfos.filter((t) => t.type === 'page'); + }; + + beforeAll(async () => { + server = createServer((req, res) => { + const files: Record = { + '/page.html': ['text/html', 'upstream'], + '/app.js': ['text/javascript', "document.title = 'upstream';"], + }; + const file = files[new URL(req.url ?? '/', 'http://x').pathname]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0] }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + home = await mkdtemp(join(tmpdir(), 'console-editor-everyday-chrome-')); + profile = join(home, '.config/google-chrome'); + mkdirSync(profile, { recursive: true }); + const now = Date.now(); + overrides.push({ id: 'o1', kind: 'Script', sourceUrl: `${origin}/app.js`, match: defaultMatcherFor(`${origin}/app.js`), content: "document.title = 'overridden';", enabled: true, originalHash: null, createdAt: now, updatedAt: now }); + // "Your" Chrome: its everyday profile, remote debugging on, one tab of your own open. + const sandbox = process.getuid?.() === 0 ? ['--no-sandbox'] : []; + everyday = spawn(chromium.executablePath(), ['--headless=new', ...sandbox, `--user-data-dir=${profile}`, '--remote-debugging-port=0', '--no-first-run', `${origin}/page.html?mine`], { stdio: 'ignore' }); + await waitFor(() => readActivePort(profile)); + driven = new DrivenBrowsers({ registry: { get: async () => browser }, sources, userData: join(home, 'user-data'), home, send: (event) => events.push(event) }); + }); + + afterAll(async () => { + driven?.dispose(); + everyday?.kill(); + await new Promise((r) => server?.close(r)); + await rm(home, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('is found through the address it wrote in its everyday profile, and listed as debuggable while it runs', async () => { + expect(await reachableEveryday(browser, home, 'linux')).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\//); + const prefs = new BrowserStore(join(home, 'browsers.json')); + await prefs.load(); + const registry = new BrowserRegistry({ prefs, send: () => undefined, find: async () => [browser], home }); + expect((await registry.list())[0]).toMatchObject({ id: browser.id, debuggable: true }); + }); + + it('opens the page in a tab of its own there, served the overrides, and leaves your own tab alone', async () => { + await driven.open(browser.id, `${origin}/page.html`, true); + await waitFor(async () => (await driven.read())[0]?.tabs.some((t) => t.title === 'overridden')); + expect(driven.list()).toEqual([{ id: `${browser.id}#everyday`, browserId: browser.id, everyday: true, name: 'Google Chrome · your profile', version: expect.stringMatching(/^\d+\./), tabs: [expect.objectContaining({ url: `${origin}/page.html`, title: 'overridden' })] }]); + // Your own tab isn't listed, attached or served: it still shows the page as the server sent it. + const mine = (await pages()).find((p) => p.url.endsWith('?mine'))!; + expect(mine.attached).toBe(false); + }); + + it('is let go of as it was: the browser still runs, your tab still open', async () => { + driven.stop(`${browser.id}#everyday`); + expect(driven.list()).toEqual([]); + expect((await pages()).some((p) => p.url.endsWith('?mine'))).toBe(true); + expect(await reachableEveryday(browser, home, 'linux')).not.toBeNull(); + }); + + it("isn't found once it has quit, although its profile keeps the address", async () => { + const exited = new Promise((r) => everyday.once('exit', r)); + everyday.kill(); + await exited; + expect(await readActivePort(profile)).not.toBeNull(); + expect(await reachableEveryday(browser, home, 'linux')).toBeNull(); + await expect(driven.open(browser.id, `${origin}/page.html`, true)).rejects.toThrow('Turn on remote debugging in Google Chrome first'); + }); +}); diff --git a/test/integration/overlay.chromium.test.ts b/test/integration/overlay.chromium.test.ts new file mode 100644 index 00000000..cd267d45 --- /dev/null +++ b/test/integration/overlay.chromium.test.ts @@ -0,0 +1,109 @@ +/** + * A design laid over a page through CDP, against real Chromium, on a page whose CSP forbids images from anywhere: + * drawn all the same (a canvas in an isolated world), restyled, kept after a reload (over the top document only, + * not in its same-site frame), out of the pointer's way, and taken off. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { captureOverCdp } from '../../src/main/shots/capture'; +import { OVERLAY_ELEMENT_ID } from '../../src/main/overlay/constants'; +import { PageOverlay } from '../../src/main/overlay/PageOverlay'; +import { DEFAULT_OVERLAY_SETTINGS } from '../../src/main/overlay/constants'; +import { chromiumAvailable, launchChromium, type ChromiumHarness } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; + +describe.skipIf(!chromiumAvailable)('design overlay in Chromium', () => { + let server: Server; + let chrome: ChromiumHarness; + let page: Page; + let transport: Awaited>['transport']; + let overlay: PageOverlay; + const design = encodePng(200, 100, () => [255, 0, 0]).toString('base64'); + const opaque = { ...DEFAULT_OVERLAY_SETTINGS, opacity: 1, x: 10, y: 20 }; + const pixel = async (x: number, y: number) => decodePng((await captureOverCdp(transport, { area: 'viewport' })).bytes).at(x, y); + + beforeAll(async () => { + server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-src 'self'" }); + // The page, with a same-site frame out of the way (bottom right), which must not get a design of its own. + if (req.url === '/frame.html') return void res.end('f'); + res.end(''); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + chrome = await launchChromium(); + ({ page, transport } = await chrome.newPage()); + await page.setViewportSize({ width: 400, height: 300 }); + await transport.send('Page.enable'); + await page.goto(`http://127.0.0.1:${(server.address() as AddressInfo).port}/`); + overlay = new PageOverlay(transport); + }); + + afterAll(async () => { + await chrome?.close(); + await new Promise((r) => server?.close(r)); + }); + + it('draws the design over the page where it is placed, although the CSP allows no images', async () => { + await overlay.show(design, opaque, 200, 100); + await expect.poll(() => pixel(15, 25)).toEqual([255, 0, 0, 255]); + expect(await pixel(5, 5)).toEqual([0, 0, 255, 255]); + expect(await pixel(215, 125)).toEqual([0, 0, 255, 255]); + }); + + it('lets the pointer through to the page under it', async () => { + expect(await page.evaluate(() => document.elementFromPoint(50, 50)?.id)).toBe('under'); + }); + + it('takes a new style: see-through, blended, moved, hidden', async () => { + await overlay.restyle({ ...opaque, opacity: 0.5 }, 200, 100); + const [r, , b] = await pixel(15, 25); + expect(r).toBeGreaterThan(100); + expect(b).toBeGreaterThan(100); + await overlay.restyle({ ...opaque, blend: 'difference' }, 200, 100); + // Red on blue, as a difference: magenta. + expect(await pixel(15, 25)).toEqual([255, 0, 255, 255]); + await overlay.restyle({ ...opaque, x: 100 }, 200, 100); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + expect(await pixel(105, 25)).toEqual([255, 0, 0, 255]); + await overlay.restyle({ ...opaque, hidden: true }, 200, 100); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + await overlay.restyle(opaque, 200, 100); + }); + + it('is there again after a reload, with its latest style, and puts itself back when the page removes it', async () => { + await overlay.restyle({ ...opaque, x: 50 }, 200, 100); + await page.reload(); + await expect.poll(() => pixel(55, 25)).toEqual([255, 0, 0, 255]); + expect(await pixel(15, 25)).toEqual([0, 0, 255, 255]); + // The frame's new document ran the same scripts: it stays as it is. + // (A reload waits for the frame's load too.) + const frame = page.frames().find((f) => f.url().endsWith('/frame.html'))!; + expect(await frame.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(false); + expect(await pixel(310, 210)).toEqual([0, 255, 0, 255]); + await page.evaluate((id) => document.getElementById(id)?.remove(), OVERLAY_ELEMENT_ID); + await expect.poll(() => page.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(true); + }); + + it('blends as a difference with the white a page without a background shows, and gives the page its own back', async () => { + const root = () => page.evaluate(() => document.documentElement.style.backgroundColor); + await page.evaluate(() => document.body.style.setProperty('background', 'none')); + await overlay.restyle({ ...opaque, blend: 'difference' }, 200, 100); + // Red from white: cyan. + expect(await pixel(15, 25)).toEqual([0, 255, 255, 255]); + expect(await root()).toBe('rgb(255, 255, 255)'); + await overlay.restyle(opaque, 200, 100); + expect(await root()).toBe(''); + await page.evaluate(() => document.body.style.setProperty('background', '#0000ff')); + }); + + it('comes off, and stays off after a reload', async () => { + await overlay.remove(); + expect(await pixel(55, 25)).toEqual([0, 0, 255, 255]); + await page.reload(); + expect(await page.evaluate((id) => !!document.getElementById(id), OVERLAY_ELEMENT_ID)).toBe(false); + expect(await pixel(55, 25)).toEqual([0, 0, 255, 255]); + }); +}); diff --git a/test/integration/playwright.chromium.test.ts b/test/integration/playwright.chromium.test.ts new file mode 100644 index 00000000..70fee35e --- /dev/null +++ b/test/integration/playwright.chromium.test.ts @@ -0,0 +1,140 @@ +/** + * A browser driven through Playwright, as the WebKit build the app downloads is, with Playwright's Chromium standing + * in for WebKit (whose build can't be downloaded here; the driver only speaks Playwright's API, the same for both): a + * script override served, a header rule and a block rule applied, a cross-origin request an override answers and a + * GraphQL operation named by the body, changes served and the tabs reloaded, a tab captured and the whole page at a + * viewport given, the design laid over its tabs and out of captures, and letting go of it quitting it, its cookies kept. + */ +import { existsSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chromium } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { FoundBrowser } from '../../src/main/browsers'; +import { connectPlaywright } from '../../src/main/browsers/driven/playwright/connectPlaywright'; +import type { Driver, InterceptionSources } from '../../src/main/browsers/driven/types'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type Override, type Rule } from '../../src/shared/types'; +import { chromiumAvailable } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; +import { encodePng } from '../helpers/encodePng'; + +async function waitFor(fn: () => T | undefined | false | Promise, timeout = 20_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 100)); + } +} + +describe.skipIf(!chromiumAvailable)('A browser driven through Playwright (as WebKit is)', () => { + let server: Server; + let origin: string; + let other: string; + let userData: string; + let driver: Driver; + let closed = 0; + const overrides: Override[] = []; + const rules: Rule[] = []; + const sources = { store: { list: () => overrides, base: async () => '' }, rules: { list: () => rules }, settings: { get: () => DEFAULT_SETTINGS } } as unknown as InterceptionSources; + const browser: FoundBrowser = { id: 'playwright:webkit', name: 'WebKit', engine: 'webkit', command: [], urlAt: 0, iconFile: null, app: null, program: chromium.executablePath(), added: false }; + const now = Date.now(); + const override = (sourceUrl: string, kind: Override['kind'], content: string, extra: Partial = {}): Override => ({ id: `o${overrides.length}`, kind, sourceUrl, match: defaultMatcherFor(sourceUrl), content, enabled: true, originalHash: null, createdAt: now, updatedAt: now, ...extra }); + const answered = { status: 200, delayMs: 0, headers: [], send: false, patch: false }; + const titled = async (url: string) => { + await driver.readTabs(); + return driver.list().tabs.find((t) => t.url === url)?.title ?? ''; + }; + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + if (path === '/graphql') return void res.writeHead(req.method === 'OPTIONS' ? 404 : 200, { 'access-control-allow-origin': '*' }).end('upstream'); + const ask = `(name) => fetch('${other}/graphql', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ operationName: name }) }).then((r) => r.text(), () => 'refused')`; + const script = `Promise.all([fetch('/api').then((r) => r.headers.get('x-rule')), fetch('/blocked.js').then(() => 'loaded', () => 'blocked'), fetch('${other}/data', { method: 'PUT' }).then((r) => r.text(), () => 'refused'), (${ask})('GetUser'), (${ask})('GetCart')]).then((all) => { document.title = [window.appValue, ...all].join(' '); });`; + const files: Record = { + '/page.html': ['text/html', `upstream`], + '/design.html': ['text/html', `-`], + '/app.js': ['text/javascript', "window.appValue = 'upstream';"], + '/api': ['application/json', '{}'], + '/blocked.js': ['text/javascript', ''], + }; + const file = files[path]; + if (!file) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': file[0], 'set-cookie': 'visited=yes; Path=/' }).end(file[1]); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + origin = `http://127.0.0.1:${port}`; + other = `http://localhost:${port}`; + userData = await mkdtemp(join(tmpdir(), 'console-editor-playwright-')); + overrides.push( + override(`${origin}/app.js`, 'Script', "window.appValue = 'overridden';"), + override(`${other}/data`, 'Fetch', 'from-override', { request: { method: 'PUT', operation: '' }, response: answered }), + override(`${other}/graphql`, 'Fetch', 'from-graphql', { request: { method: 'POST', operation: 'GetUser' }, response: answered }), + ); + const rule = { enabled: true, resourceTypes: [], createdAt: now, updatedAt: now }; + rules.push({ ...rule, id: 'r1', action: 'headers', match: defaultMatcherFor(`${origin}/api`), headers: [{ operation: 'set', name: 'x-rule', value: 'yes' }] }, { ...rule, id: 'r2', action: 'block', match: defaultMatcherFor(`${origin}/blocked.js`) }); + const deps = { listedAs: { id: browser.id, name: browser.name, everyday: false }, home: userData, sources, userData, changed: () => undefined, closed: () => closed++ }; + driver = await connectPlaywright({ type: chromium, executablePath: chromium.executablePath(), headless: true }, browser, deps); + }); + + afterAll(async () => { + driver?.stop(); + await new Promise((r) => server?.close(r)); + await rm(userData, { recursive: true, force: true, maxRetries: 5 }); + }); + + it('serves the overrides and applies the rules, a GraphQL operation answered by the body sent', async () => { + await driver.open(`${origin}/page.html`); + await waitFor(async () => (await titled(`${origin}/page.html`)) === 'overridden yes blocked from-override from-graphql upstream'); + expect(driver.list()).toMatchObject({ id: browser.id, browserId: browser.id, name: 'WebKit', everyday: false, version: expect.stringMatching(/^\d+\./) }); + }); + + it('serves overrides as they change once its tabs reload', async () => { + overrides[0] = { ...overrides[0], content: "window.appValue = 'changed';", updatedAt: Date.now() }; + await driver.refresh(); + await driver.reload(); + await waitFor(async () => (await titled(`${origin}/page.html`)).startsWith('changed yes blocked')); + }); + + it('captures a tab, and the whole page at a viewport given (in a context made for it)', async () => { + const [tab] = driver.list().tabs; + const { image } = await driver.capture(tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([255, 0, 0, 255]); + const { image: at } = await driver.captureAt(`${origin}/page.html`, { width: 400, height: 300, scale: 2 }); + expect(at).toMatchObject({ width: 800, height: 600, scale: 2, viewport: { width: 400, height: 300 } }); + expect(driver.list().tabs).toHaveLength(1); + }); + + it('lays the design over its tabs at its width, keeps it after a reload and out of captures, and takes it off', async () => { + const url = `${origin}/design.html`; + await driver.open(url); + const settings = { opacity: 0.5, blend: 'normal', invert: false, x: 0, y: 0, attached: 'page', hidden: false, fitWidth: true } as const; + const design = { key: 'd1', base64: encodePng(40, 30, () => [255, 0, 0]).toString('base64'), width: 300, height: 200, settings }; + await driver.setDesign(design); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + const tab = driver.list().tabs.find((t) => t.url === url)!; + const { image } = await driver.capture(tab.id, 'viewport'); + expect(decodePng(image.bytes).at(5, 5)).toEqual([0, 0, 255, 255]); + expect(image.viewport.width).not.toBe(300); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + await driver.reload(); + await waitFor(async () => (await titled(url)) === 'design 0.5 300'); + await driver.setDesign(null); + await waitFor(async () => /^no design \d+$/.test(await titled(url)) && !(await titled(url)).endsWith(' 300')); + }); + + it('quits it once let go of, keeping its cookies for next time', async () => { + driver.stop(); + const state = join(userData, 'browsers', 'playwright_webkit', 'storage-state.json'); + await waitFor(() => existsSync(state)); + expect((JSON.parse(await import('node:fs/promises').then((fs) => fs.readFile(state, 'utf8'))) as { cookies: Array<{ name: string }> }).cookies.map((c) => c.name)).toContain('visited'); + expect(closed).toBe(0); + }); +}); diff --git a/test/integration/shots.chromium.test.ts b/test/integration/shots.chromium.test.ts new file mode 100644 index 00000000..bc1e3b46 --- /dev/null +++ b/test/integration/shots.chromium.test.ts @@ -0,0 +1,135 @@ +/** + * Captures against real Chromium: what the viewport shows, the whole page (past the viewport, which stays as it + * was; in parts, joined, past a texture's side), and an element picked in a cross-site frame (its own process and session), placed through its frame's + * owner and captured where it is on the page. + */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from 'playwright-core'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { PageInterception } from '../../src/main/engine/PageInterception'; +import { FrameServices } from '../../src/main/PageController/FrameServices'; +import { captureOverCdp } from '../../src/main/shots/capture'; +import { DEFAULT_SETTINGS, type AppEvent, type InspectedComponent, type Settings } from '../../src/shared/types'; +import { chromiumAvailable, launchChromium, type ChromiumHarness } from '../helpers/chromium'; +import { decodePng } from '../helpers/decodePng'; + +async function waitFor(fn: () => T | undefined | Promise, timeout = 15_000): Promise { + const deadline = Date.now() + timeout; + for (;;) { + const value = await fn(); + if (value) return value; + if (Date.now() > deadline) throw new Error('Timed out'); + await new Promise((r) => setTimeout(r, 50)); + } +} + +describe.skipIf(!chromiumAvailable)('captures in Chromium', () => { + let server: Server; + let port: number; + let chrome: ChromiumHarness; + let page: Page; + let transport: Awaited>['transport']; + let services: FrameServices; + let interception: PageInterception; + const events: AppEvent[] = []; + const settings: Settings = { ...DEFAULT_SETTINGS }; + + beforeAll(async () => { + server = createServer((req, res) => { + const path = new URL(req.url ?? '/', 'http://x').pathname; + const pages: Record = { + // A tall page: blue down to 1000 px, green below; a cross-site frame 300 px down, 100 in. + '/shell.html': `
`, + // In it, a red box 50 px in and 60 down. + '/frame.html': '
', + }; + const html = pages[path]; + if (!html) return void res.writeHead(404).end(); + res.writeHead(200, { 'content-type': 'text/html' }).end(html); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + port = (server.address() as AddressInfo).port; + chrome = await launchChromium(); + ({ page, transport } = await chrome.newPage()); + await page.setViewportSize({ width: 800, height: 600 }); + services = new FrameServices(() => settings, (e) => events.push(e)); + interception = new PageInterception({ transport, getOverrides: () => [], getRules: () => [], getSettings: () => settings, emit: () => undefined, sessions: services }); + await interception.attach(); + await page.goto(`http://127.0.0.1:${port}/shell.html`); + await page.frameLocator('iframe').locator('#red').waitFor(); + }); + + afterAll(async () => { + interception?.detach(); + await chrome?.close(); + await new Promise((r) => server?.close(r)); + }); + + it('captures what the viewport shows', async () => { + const image = await captureOverCdp(transport, { area: 'viewport' }); + expect(image).toMatchObject({ width: 800, height: 600, scale: 1, viewport: { width: 800, height: 600 } }); + expect(decodePng(image.bytes).at(10, 10)).toEqual([0, 0, 255, 255]); + }); + + it('captures the whole page past the viewport, and leaves the viewport and scroll as they were', async () => { + await page.evaluate(() => window.scrollTo(0, 700)); + // The document's width: the viewport's less its scrollbar. + const documentWidth = await page.evaluate(() => document.documentElement.clientWidth); + const image = await captureOverCdp(transport, { area: 'page' }); + expect(image).toMatchObject({ width: documentWidth, height: 2400, scale: 1, viewport: { width: 800, height: 600 } }); + const png = decodePng(image.bytes); + expect(png.at(10, 10)).toEqual([0, 0, 255, 255]); + expect(png.at(10, 2300)).toEqual([0, 255, 0, 255]); + // (The scrollbar can hide afterwards here: a quirk of Playwright's viewport emulation, which the app doesn't use.) + expect(await page.evaluate(() => [window.innerWidth, window.innerHeight, window.scrollY])).toEqual([800, 600, 700]); + }); + + it('captures an element picked in a cross-site frame, where it is on the page', async () => { + await page.evaluate(() => window.scrollTo(0, 100)); + await services.inspector.startPicking(); + const red = page.frameLocator('iframe').locator('#red'); + const box = (await red.boundingBox())!; + await page.mouse.move(box.x + 10, box.y + 10); + await page.mouse.click(box.x + 10, box.y + 10); + const picked = await waitFor(() => events.find((e): e is Extract => e.type === 'inspect-picked')); + const { pickId } = picked.component as InspectedComponent; + + // 300 down and 100 in for the frame, 60 and 50 more for the box, 100 scrolled away: where the viewport shows it. + expect(await services.inspector.elementBox(pickId)).toEqual({ x: 150, y: 260, width: 120, height: 80 }); + const image = await captureOverCdp(transport, { area: 'element', box: await services.inspector.elementBox(pickId) }); + expect(image).toMatchObject({ width: 120, height: 80 }); + const png = decodePng(image.bytes); + for (const [x, y] of [[0, 0], [60, 40], [119, 79]]) expect(png.at(x, y)).toEqual([255, 0, 0, 255]); + }); + + it('captures a page taller than a texture in parts, joined without a gap or a repeat, down to the tallest a canvas draws', async () => { + const tall = await chrome.newPage(); + try { + await tall.page.setViewportSize({ width: 60, height: 600 }); + // 40,000 rows, each its own colour: its position, red the low byte and green the high one. + await tall.page.setContent(''); + await tall.page.evaluate(() => { + for (let y = 0; y < 40_000; y++) { + const row = document.createElement('div'); + row.style.cssText = `height:1px;background:rgb(${y % 256},${y >> 8},0)`; + document.body.append(row); + } + }); + const rowOf = (pixel: number[]) => pixel[0] + pixel[1] * 256; + for (const scale of [1, 2]) { + await tall.transport.send('Emulation.setDeviceMetricsOverride', { width: 60, height: 600, deviceScaleFactor: scale, mobile: false }); + const width = await tall.page.evaluate(() => document.documentElement.clientWidth); + const image = await captureOverCdp(tall.transport, { area: 'page' }); + const cssHeight = Math.floor(32_767 / scale); + expect(image).toMatchObject({ width: width * scale, height: cssHeight * scale, scale }); + const png = decodePng(image.bytes); + const wrong = Array.from({ length: image.height }, (_, y) => y).filter((y) => rowOf(png.at(1, y)) !== Math.floor(y / scale)); + expect(wrong.slice(0, 5)).toEqual([]); + } + } finally { + await tall.transport.detach(); + await tall.page.close(); + } + }); +}); diff --git a/test/perf/renderer/diff.perf.test.ts b/test/perf/renderer/diff.perf.test.ts new file mode 100644 index 00000000..2632fdda --- /dev/null +++ b/test/perf/renderer/diff.perf.test.ts @@ -0,0 +1,49 @@ +/** + * How long comparing two tall captures pixel by pixel takes, in the diff worker's function: 1440 × 10,000 pixels of + * "text" (small dark boxes with smoothed edges), its edges smoothed another way in the second image (as another browser + * draws them) and a few boxes really changed, so most of the time goes to telling anti-aliasing apart. It prints the + * median of a few runs, and fails only past a budget well above what a laptop measures. + */ +import { describe, expect, it } from 'vitest'; +import { diffPixels } from '@/features/shot/compare/lib/diffPixels'; + +const WIDTH = 1440; +const HEIGHT = 10_000; +const RUNS = 3; +const BUDGET_MS = 15_000; + +/** The runner's stderr, where the results go (a passing test's console is hidden); this file has no Node types. */ +const { stderr } = (globalThis as unknown as { process: { stderr: { write(text: string): void } } }).process; + +/** A page of "text": 12 × 8 boxes on a 20 px grid, their edges grey (`edge`), every 97th box missing when `changed`. */ +function page(edge: number, changed: boolean) { + const data = new Uint8ClampedArray(WIDTH * HEIGHT * 4).fill(255); + for (let y = 0; y < HEIGHT; y++) { + for (let x = 0; x < WIDTH; x++) { + const [cx, cy] = [x % 20, y % 20]; + if (cx > 13 || cy > 9 || (changed && (Math.floor(x / 20) + Math.floor(y / 20) * 72) % 97 === 0)) continue; + const value = cx === 0 || cx === 13 || cy === 0 || cy === 9 ? edge : 0; + const i = (y * WIDTH + x) * 4; + data[i] = data[i + 1] = data[i + 2] = value; + } + } + return { data, width: WIDTH, height: HEIGHT }; +} + +describe('comparing tall captures', () => { + it('compares 1440 × 10,000 pixels, telling anti-aliasing apart, within its budget', () => { + const [a, b] = [page(110, false), page(170, true)]; + const times: number[] = []; + let result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + for (let run = 0; run < RUNS; run++) { + const start = performance.now(); + result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + times.push(performance.now() - start); + } + const median = times.sort((x, y) => x - y)[Math.floor(RUNS / 2)]; + stderr.write(`\n${'diff 1440 × 10,000 (anti-aliasing told apart)'.padEnd(64)} ${`${median.toFixed(0)} ms`.padStart(9)} budget ${BUDGET_MS} ms\n`); + stderr.write(` ${result.differing} differing, ${result.smoothed} anti-aliasing, ${result.regions.length} areas\n`); + expect(result.smoothed).toBeGreaterThan(result.differing); + expect(median).toBeLessThan(BUDGET_MS); + }); +}); diff --git a/test/renderer/bridge-start.test.ts b/test/renderer/bridge-start.test.ts index b4be0f69..254df48f 100644 --- a/test/renderer/bridge-start.test.ts +++ b/test/renderer/bridge-start.test.ts @@ -25,6 +25,10 @@ const api = vi.hoisted(() => ({ listResources: vi.fn(), listNetworkRequests: vi.fn(), listHeldRequests: vi.fn(), + listShots: vi.fn(), + getOverlay: vi.fn(), + listBrowsers: vi.fn(), + listDriven: vi.fn(), getPageState: vi.fn(), sessionFlushed: vi.fn(), })); @@ -114,6 +118,10 @@ describe('start bridge', () => { api.listResources.mockResolvedValue([]); api.listNetworkRequests.mockResolvedValue([]); api.listHeldRequests.mockResolvedValue([]); + api.listShots.mockResolvedValue([]); + api.getOverlay.mockResolvedValue(null); + api.listBrowsers.mockResolvedValue([]); + api.listDriven.mockResolvedValue([]); api.getPageState.mockResolvedValue(PAGE); }); afterEach(() => { diff --git a/test/renderer/pageTabs.test.ts b/test/renderer/pageTabs.test.ts index 4e525a12..2ed3b6ed 100644 --- a/test/renderer/pageTabs.test.ts +++ b/test/renderer/pageTabs.test.ts @@ -92,7 +92,7 @@ describe('page tabs', () => { expect(isPageDirty(newRulePage('n1', EDITED))).toBe(true); expect(isPageDirty(STACK)).toBe(false); expect(isPageDirty(COMPONENT)).toBe(false); - expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace' }); + expect(PAGE_SCOPES).toEqual({ 'whats-new': 'app', stack: 'app', rule: 'workspace', 'new-rule': 'workspace', component: 'workspace', shot: 'workspace', compare: 'workspace', group: 'workspace' }); }); it("closes the workspace's rule and new-rule pages with its tabs; What's New and the Page stack stay", () => { diff --git a/test/renderer/shots.test.ts b/test/renderer/shots.test.ts new file mode 100644 index 00000000..bf6eb220 --- /dev/null +++ b/test/renderer/shots.test.ts @@ -0,0 +1,190 @@ +/** + * Captures and designs in the renderer: a shot's line and age, the search, the pages that follow the list (closing + * a deleted shot's, renaming a renamed one's), and capturing an element picked for it (and not the next ordinary pick). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Shot } from '../../src/shared/types'; +import { useTabStore } from '@/entities/editor-tab'; +import { matchesShot, shotDetail, useShotStore } from '@/entities/shot'; +import { timeAgo } from '@/entities/shot/lib/timeAgo'; +import { captureShot, pickingStarted, pickToCapture, takePickCapture } from '@/features/shot/capture'; +import { openShot, receiveShots } from '@/features/shot/open-shot'; + +const api = vi.hoisted(() => ({ captureShot: vi.fn(), captureElementShot: vi.fn(), startPicking: vi.fn(async () => {}) })); +const toast = vi.hoisted(() => Object.assign(vi.fn(() => 'toast-1'), { dismiss: vi.fn(), update: vi.fn() })); + +vi.mock('@/shared/api', () => ({ api, onAppEvent: () => () => {}, errorMessage: (err: unknown) => (err as Error).message })); +vi.mock('@/shared/ui/toast', () => ({ toast })); +vi.mock('@/shared/monaco', () => ({ + monaco: { editor: { createModel: () => ({}) }, Uri: { from: () => ({}) } }, + languageFor: () => 'javascript', + setModelSchema: () => {}, + editorHasFocus: () => false, + dismissEditorWidgets: () => {}, + triggerInActiveEditor: () => {}, +})); + +const HOUR = 60 * 60 * 1000; + +const shot = (id: string, extra: Partial = {}): Shot => ({ + id, + kind: 'capture', + name: `${id}.png`, + width: 2880, + height: 1800, + scale: 2, + pageUrl: 'https://shop.test/cart', + browser: { id: 'app', name: 'Chromium', version: '152.0.7390.54' }, + viewport: { width: 1440, height: 900 }, + area: 'viewport', + group: null, + createdAt: 0, + updatedAt: 0, + ...extra, +}); + +beforeEach(() => { + vi.clearAllMocks(); + useTabStore.setState({ tabs: [], sources: [], pages: [], activeId: null }); + useShotStore.getState().setAll([]); +}); + +describe('A shot’s line', () => { + it('says how long ago, in its largest whole unit', () => { + expect(timeAgo(0, 30_000)).toBe('just now'); + expect(timeAgo(0, 60_000)).toBe('1 min ago'); + expect(timeAgo(0, 15 * HOUR + 59 * 60_000)).toBe('15 h ago'); + expect(timeAgo(0, 24 * HOUR)).toBe('1 day ago'); + expect(timeAgo(0, 50 * HOUR)).toBe('2 days ago'); + expect(timeAgo(10, 0)).toBe('just now'); + }); + + it("names a capture's kind, browser, viewport and age, and a design's width", () => { + expect(shotDetail(shot('a'), 15 * HOUR)).toBe('Capture · Chromium 152 · 1440 × 900 · 15 h ago'); + expect(shotDetail(shot('b', { kind: 'design', browser: null, viewport: null, width: 2880, scale: 2 }), 0)).toBe('Design · 1440 wide · just now'); + }); + + it('matches every word of a search in its name, address or browser', () => { + expect(matchesShot(shot('hero'), 'hero shop.test')).toBe(true); + expect(matchesShot(shot('hero'), 'chromium')).toBe(true); + expect(matchesShot(shot('hero'), 'hero firefox')).toBe(false); + expect(matchesShot(shot('hero'), '')).toBe(true); + }); +}); + +describe('Shot pages', () => { + it('close with their shot, and take its new name', () => { + openShot(shot('a')); + openShot(shot('b')); + expect(useTabStore.getState().pages.map((p) => p.title)).toEqual(['a.png', 'b.png']); + receiveShots([shot('b', { name: 'hero.png' })]); + expect(useTabStore.getState().pages.map((p) => [p.id, p.title])).toEqual([['shot:b', 'hero.png']]); + expect(useShotStore.getState().shots.map((s) => s.name)).toEqual(['hero.png']); + }); +}); + +describe('Capturing', () => { + it('captures the page and offers to open the capture; says why when it fails', async () => { + api.captureShot.mockResolvedValue(shot('a')); + const onOpen = vi.fn(); + expect(await captureShot('page', null, onOpen)).toEqual(shot('a')); + expect(api.captureShot).toHaveBeenCalledWith('page'); + const { action } = (toast.mock.calls.at(-1) as unknown as [{ title: string; action: { onClick(): void } }])[0]; + action.onClick(); + expect(onOpen).toHaveBeenCalledWith(shot('a')); + + api.captureShot.mockRejectedValue(new Error('Show the website to capture it')); + expect(await captureShot('viewport', null)).toBeNull(); + expect(toast).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Could not capture the page', description: 'Show the website to capture it', tone: 'danger' })); + }); + + it('captures the element picked for it, once, and not one picked after picking started again', async () => { + api.captureElementShot.mockResolvedValue(shot('el', { area: 'element' })); + await pickToCapture(); + expect(api.startPicking).toHaveBeenCalledTimes(1); + pickingStarted(); + takePickCapture('pick-1', openShot); + await vi.waitFor(() => expect(api.captureElementShot).toHaveBeenCalledWith('pick-1')); + takePickCapture('pick-2', openShot); + expect(api.captureElementShot).toHaveBeenCalledTimes(1); + + // Esc, then picking again for the inspector: that pick is an ordinary one. + await pickToCapture(); + pickingStarted(); + pickingStarted(); + takePickCapture('pick-3', openShot); + expect(api.captureElementShot).toHaveBeenCalledTimes(1); + }); +}); + +describe('Comparing', () => { + /** An image of one colour, with a box of another. */ + const image = (width: number, height: number, fill: number[], box?: { x: number; y: number; size: number; color: number[] }) => { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const inBox = box && x >= box.x && y >= box.y && x < box.x + box.size && y < box.y + box.size; + data.set(inBox ? box.color : fill, (y * width + x) * 4); + } + } + return { data, width, height }; + }; + const WHITE = [255, 255, 255, 255]; + const RED = [255, 0, 0, 255]; + + it('finds nothing between the same images, and ignores colours closer than the threshold', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(40, 30, WHITE); + expect(diffPixels(a, image(40, 30, WHITE), { x: 0, y: 0 }, 0.1)).toMatchObject({ differing: 0, total: 1200, regions: [] }); + expect(diffPixels(a, image(40, 30, [250, 250, 250, 255]), { x: 0, y: 0 }, 0.1).differing).toBe(0); + }); + + it('counts the pixels that differ, marks them, and boxes each area', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(100, 100, WHITE); + const b = image(100, 100, WHITE, { x: 60, y: 70, size: 10, color: RED }); + const result = diffPixels(a, b, { x: 0, y: 0 }, 0.1); + expect(result.differing).toBe(100); + expect(result.regions).toEqual([{ x: 48, y: 64, width: 32, height: 16 }]); + expect([...result.image.data.slice((75 * 100 + 65) * 4, (75 * 100 + 65) * 4 + 4)]).toEqual([255, 0, 64, 255]); + // A pixel the same in both is a faded grey. + expect(result.image.data[0]).toBeGreaterThan(240); + }); + + it('moves the second image by the offset, and counts what only one covers as differing', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + const a = image(50, 50, WHITE, { x: 10, y: 10, size: 5, color: RED }); + const b = image(50, 50, WHITE, { x: 7, y: 6, size: 5, color: RED }); + // The boxes line up once moved; only the pixels outside the 47 × 46 both cover differ. + expect(diffPixels(a, b, { x: 3, y: 4 }, 0.1)).toMatchObject({ differing: 53 * 54 - 47 * 46, total: 53 * 54 }); + const taller = diffPixels(image(10, 10, WHITE), image(10, 15, WHITE), { x: 0, y: 0 }, 0.1); + expect(taller).toMatchObject({ differing: 50, total: 150, regions: [{ x: 0, y: 0, width: 10, height: 15 }] }); + }); + + it('tells anti-aliasing apart: an edge smoothed another way shows in yellow, uncounted; a thin line still differs', async () => { + const { diffPixels } = await import('@/features/shot/compare/lib/diffPixels'); + // Black on the left, white on the right, the edge between them smoothed grey: darker in one, lighter in the other. + const picture = (edge: number, line: boolean) => { + const data = new Uint8ClampedArray(20 * 20 * 4); + for (let y = 0; y < 20; y++) { + for (let x = 0; x < 20; x++) { + const value = x < 10 ? 0 : x === 10 ? edge : line && x === 17 ? 0 : 255; + data.set([value, value, value, 255], (y * 20 + x) * 4); + } + } + return { data, width: 20, height: 20 }; + }; + const result = diffPixels(picture(100, false), picture(180, true), { x: 0, y: 0 }, 0.1); + expect(result).toMatchObject({ differing: 20, smoothed: 20, regions: [{ x: 16, y: 0, width: 4, height: 20 }] }); + expect([...result.image.data.slice((5 * 20 + 10) * 4, (5 * 20 + 10) * 4 + 4)]).toEqual([255, 200, 0, 255]); + expect([...result.image.data.slice((5 * 20 + 17) * 4, (5 * 20 + 17) * 4 + 4)]).toEqual([255, 0, 64, 255]); + }); + + it('says how much differs as a share a person reads', async () => { + const { differingShare } = await import('@/widgets/editor-panel/ui/ComparePage/differingShare'); + expect(differingShare(0, 100)).toBe('0%'); + expect(differingShare(1, 1_000_000)).toBe('< 0.01%'); + expect(differingShare(402_500, 960_000)).toBe('41.9%'); + expect(differingShare(5, 1000)).toBe('0.50%'); + }); +}); diff --git a/test/unit/bidi.test.ts b/test/unit/bidi.test.ts new file mode 100644 index 00000000..cdcc98f1 --- /dev/null +++ b/test/unit/bidi.test.ts @@ -0,0 +1,137 @@ +/** + * Driving Firefox over WebDriver BiDi, the parts that need no browser: the resource type each request is matched as, + * what rules read from a request, the head an override answers with before its request is sent, and the address + * Firefox writes in its profile. + */ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { readBidiPort } from '../../src/main/browsers/driven/firefox/readBidiPort'; +import { requestBodyOf } from '../../src/main/engine/bidi/requestBodyOf'; +import { requestOf } from '../../src/main/engine/bidi/requestOf'; +import { resourceTypeOf } from '../../src/main/engine/bidi/resourceTypeOf'; +import { answerContext, decideRequest } from '../../src/main/engine/answering'; +import { servedHead } from '../../src/main/engine/answering/servedHead'; +import type { BidiAnswerContext, BidiRequestData } from '../../src/main/engine/bidi/types'; +import { defaultMatcherFor } from '../../src/shared/matcher'; +import { DEFAULT_SETTINGS, type Override, type Rule } from '../../src/shared/types'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-bidi-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const data = (extra: Partial = {}): BidiRequestData => ({ request: '1', url: 'https://shop.test/app.js', method: 'GET', headers: [], ...extra }); +const header = (name: string, value: string) => ({ name, value: { type: 'string' as const, value } }); + +describe('BiDi requests', () => { + it('are matched as the resource type the engine knows: by destination, else by initiator type', () => { + const types = (['document', 'iframe', 'style', 'script', 'image', 'font', 'video', 'worker'] as const).map((destination) => resourceTypeOf(data({ destination }))); + expect(types).toEqual(['Document', 'Document', 'Stylesheet', 'Script', 'Image', 'Font', 'Media', 'Other']); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'fetch' }))).toBe('Fetch'); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'xmlhttprequest' }))).toBe('XHR'); + expect(resourceTypeOf(data({ destination: '', initiatorType: 'beacon' }))).toBe('Ping'); + expect(resourceTypeOf(data({ destination: '', initiatorType: null }))).toBe('Other'); + expect(resourceTypeOf(data({ destination: 'constructor' }))).toBe('Other'); + }); + + it('give rules their address, method and headers by lower-case name', () => { + expect(requestOf(data({ method: 'PUT', headers: [header('Origin', 'https://app.test'), header('X-Thing', '1')] }))).toEqual({ url: 'https://shop.test/app.js', method: 'PUT', headers: { origin: 'https://app.test', 'x-thing': '1' } }); + }); +}); + +describe('The head an override answers with before its request is sent', () => { + const now = Date.now(); + const override = (extra: Partial = {}): Override => ({ id: 'o1', kind: 'Script', sourceUrl: 'https://shop.test/app.js', match: defaultMatcherFor('https://shop.test/app.js'), content: '1', enabled: true, originalHash: null, createdAt: now, updatedAt: now, ...extra }); + const ctx = (rules: Rule[] = [], overrides: Override[] = []) => answerContext({ getOverrides: () => overrides, getRules: () => rules, getSettings: () => DEFAULT_SETTINGS }); + const names = (headers: { name: string; value: string }[]) => Object.fromEntries(headers.map((h) => [h.name.toLowerCase(), h.value])); + + it("has its kind's content type, and isn't kept in the cache", () => { + const head = servedHead(override(), requestOf(data()), 'Script', ctx()); + expect(head.status).toBe(200); + expect(names(head.headers)).toMatchObject({ 'content-type': expect.stringMatching(/javascript.*utf-8/i), 'cache-control': 'no-store' }); + }); + + it("is readable from another origin, and takes a response override's status and header changes, then the rules", () => { + const fetchOverride = override({ kind: 'Fetch', response: { status: 201, delayMs: 0, headers: [{ operation: 'set', name: 'x-own', value: 'yes' }], send: false, patch: false } }); + const rule: Rule = { id: 'r1', action: 'headers', match: defaultMatcherFor('https://shop.test/app.js'), resourceTypes: [], enabled: true, createdAt: now, updatedAt: now, headers: [{ operation: 'set', name: 'x-rule', value: 'on' }] }; + const head = servedHead(fetchOverride, requestOf(data({ headers: [header('Origin', 'https://app.test')] })), 'Fetch', ctx([rule])); + expect(head.status).toBe(201); + expect(names(head.headers)).toMatchObject({ 'x-own': 'yes', 'x-rule': 'on', 'access-control-allow-origin': 'https://app.test', 'access-control-allow-credentials': 'true' }); + }); +}); + +describe('Deciding what to do with a request before it is sent', () => { + const now = Date.now(); + const rule = (action: 'block' | 'cors', url: string): Rule => ({ id: `r-${action}`, action, match: defaultMatcherFor(url), resourceTypes: [], enabled: true, createdAt: now, updatedAt: now }); + const script: Override = { id: 'o1', kind: 'Script', sourceUrl: 'https://shop.test/app.js', match: defaultMatcherFor('https://shop.test/app.js'), content: 'patched()', enabled: true, originalHash: null, createdAt: now, updatedAt: now }; + const api: Override = { ...script, id: 'o2', kind: 'Fetch', sourceUrl: 'https://api.test/data', match: defaultMatcherFor('https://api.test/data'), content: '{}', request: { method: 'PUT', operation: '' }, response: { status: 200, delayMs: 250, headers: [], send: false, patch: false } }; + const decide = (url: string, type: string, extra: Partial = {}, rules: Rule[] = []) => decideRequest(answerContext({ getOverrides: () => [script, api], getRules: () => rules, getSettings: () => DEFAULT_SETTINGS }), requestOf(data({ url, ...extra })), type); + + it('fails one a block rule takes, before anything else', () => { + expect(decide('https://shop.test/app.js', 'Script', {}, [rule('block', 'https://shop.test/app.js')])).toEqual({ action: 'fail' }); + }); + + it("answers one an override takes, with its body and delay; a response override's preflight too", () => { + expect(decide('https://shop.test/app.js', 'Script')).toMatchObject({ action: 'answer', body: 'patched()', delayMs: 0, head: { status: 200 } }); + expect(decide('https://api.test/data', 'Fetch', { method: 'PUT' })).toMatchObject({ action: 'answer', body: '{}', delayMs: 250 }); + const preflight = decide('https://api.test/data', 'Other', { method: 'OPTIONS', headers: [header('Origin', 'https://app.test'), header('Access-Control-Request-Method', 'PUT')] }); + expect(preflight).toMatchObject({ action: 'answer', body: '', head: { status: 204 } }); + }); + + it("sends on the rest: another address, a kind the override doesn't answer, another method", () => { + expect(decide('https://shop.test/other.js', 'Script')).toEqual({ action: 'continue' }); + expect(decide('https://shop.test/app.js', 'Stylesheet')).toEqual({ action: 'continue' }); + expect(decide('https://api.test/data', 'Fetch', { method: 'GET' })).toEqual({ action: 'continue' }); + }); + + it('answers a GraphQL operation an override names by the body given, and its preflight whatever the body', () => { + const graphql: Override = { ...api, id: 'o3', sourceUrl: 'https://api.test/graphql', match: defaultMatcherFor('https://api.test/graphql'), content: '{"data":1}', request: { method: 'POST', operation: 'GetUser' } }; + const ctx = answerContext({ getOverrides: () => [graphql], getRules: () => [], getSettings: () => DEFAULT_SETTINGS }); + const post = (body?: string) => decideRequest(ctx, { ...requestOf(data({ url: 'https://api.test/graphql', method: 'POST' })), body }, 'Fetch'); + expect(post(JSON.stringify({ operationName: 'GetUser', query: 'query GetUser { user { id } }' }))).toMatchObject({ action: 'answer', body: '{"data":1}' }); + expect(post(JSON.stringify({ query: 'query GetUser { user { id } }' }))).toMatchObject({ action: 'answer' }); + expect(post(JSON.stringify({ operationName: 'GetCart' }))).toEqual({ action: 'continue' }); + expect(post()).toEqual({ action: 'continue' }); + const preflight = requestOf(data({ url: 'https://api.test/graphql', method: 'OPTIONS', headers: [header('Origin', 'https://app.test'), header('Access-Control-Request-Method', 'POST')] })); + expect(decideRequest(ctx, preflight, 'Other')).toMatchObject({ action: 'answer', head: { status: 204 } }); + }); +}); + +describe("A paused request's body", () => { + const ctx = (collector: string | null, answer: () => Promise) => { + const sent: unknown[] = []; + const send = (method: string, params: unknown) => { + sent.push([method, params]); + return answer(); + }; + return { sent, ctx: { collector, connection: { send } } as unknown as BidiAnswerContext }; + }; + + it('is read as text from the collector keeping it, and let go of there', async () => { + const text = ctx('c1', async () => ({ bytes: { type: 'string', value: '{"query":"{ a }"}' } })); + expect(await requestBodyOf(text.ctx, data({ request: 'r1', bodySize: 17 }))).toBe('{"query":"{ a }"}'); + expect(text.sent).toEqual([['network.getData', { dataType: 'request', collector: 'c1', request: 'r1', disown: true }]]); + const binary = ctx('c1', async () => ({ bytes: { type: 'base64', value: Buffer.from('{"x":1}').toString('base64') } })); + expect(await requestBodyOf(binary.ctx, data({ bodySize: 7 }))).toBe('{"x":1}'); + }); + + it("isn't asked for without a collector or a body, and is unknown when the browser didn't keep it", async () => { + const none = ctx(null, async () => ({})); + expect(await requestBodyOf(none.ctx, data({ bodySize: 10 }))).toBeUndefined(); + const empty = ctx('c1', async () => ({})); + expect(await requestBodyOf(empty.ctx, data({ bodySize: 0 }))).toBeUndefined(); + expect([...none.sent, ...empty.sent]).toEqual([]); + const gone = ctx('c1', () => Promise.reject(new Error('no such network data'))); + expect(await requestBodyOf(gone.ctx, data({ bodySize: 10 }))).toBeUndefined(); + }); +}); + +describe("Firefox's BiDi address", () => { + it('is read from the file it writes in its profile, once written and whole', async () => { + expect(await readBidiPort(tmp)).toBeNull(); + writeFileSync(join(tmp, 'WebDriverBiDiServer.json'), '{ "ws_host": "127.0.0.1", "ws_port": 9222 }'); + expect(await readBidiPort(tmp)).toBe('ws://127.0.0.1:9222/session'); + writeFileSync(join(tmp, 'WebDriverBiDiServer.json'), '{ "ws_host": "127.0.0.1"'); + expect(await readBidiPort(tmp)).toBeNull(); + }); +}); diff --git a/test/unit/browsers.test.ts b/test/unit/browsers.test.ts new file mode 100644 index 00000000..845753af --- /dev/null +++ b/test/unit/browsers.test.ts @@ -0,0 +1,391 @@ +/** + * Other browsers: finding them the way each system lists them (Linux launchers and icon themes, macOS apps, the + * Windows registry), telling their engines apart, what the user added or hid, and opening a page in one; and how a + * Chromium browser is started to be driven (its command, profile folder and debugging port's address) and its tabs kept. + */ +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { getFileIcon: async () => ({ isEmpty: () => true }) }, + nativeImage: { + createFromBuffer: (bytes: Buffer) => ({ + isEmpty: () => bytes.length === 0, + resize: () => ({ toDataURL: () => 'data:image/png;base64,SMALL' }), + }), + }, +})); + +const { parseDesktopEntry } = await import('../../src/main/browsers/findBrowsers/linux/parseDesktopEntry'); +const { splitExec } = await import('../../src/main/browsers/findBrowsers/linux/splitExec'); +const { commandOf } = await import('../../src/main/browsers/findBrowsers/linux/commandOf'); +const { isBrowserEntry } = await import('../../src/main/browsers/findBrowsers/linux/isBrowserEntry'); +const { programOf } = await import('../../src/main/browsers/findBrowsers/linux/programOf'); +const { findLinuxBrowsers } = await import('../../src/main/browsers/findBrowsers/linux/findLinuxBrowsers'); +const { labelDuplicates } = await import('../../src/main/browsers/findBrowsers/linux/labelDuplicates'); +const { parseRegQuery } = await import('../../src/main/browsers/findBrowsers/windows/parseRegQuery'); +const { programOfCommand } = await import('../../src/main/browsers/findBrowsers/windows/programOfCommand'); +const { engineOf } = await import('../../src/main/browsers/engineOf'); +const { launchEnv } = await import('../../src/main/browsers/launchEnv'); +const { BrowserRegistry } = await import('../../src/main/browsers'); +const { driveCommand } = await import('../../src/main/browsers/driven/driveCommand'); +const { profileDir } = await import('../../src/main/browsers/driven/profileDir'); +const { readActivePort } = await import('../../src/main/browsers/driven/chromium/readActivePort'); +const { DrivenTabs } = await import('../../src/main/browsers/driven/DrivenTabs'); +const { everydayProfileDir } = await import('../../src/main/browsers/driven/everyday/everydayProfileDir'); +const { isListening } = await import('../../src/main/browsers/driven/everyday/isListening'); +type DrivenTabState = import('../../src/main/browsers/driven/chromium/types').DrivenTabState; +const { BrowserStore } = await import('../../src/main/store/BrowserStore'); +type FoundBrowser = import('../../src/main/browsers').FoundBrowser; + +const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'console-editor-browsers-'))); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +/** An executable shell script at `path` (made with its folders). */ +function script(path: string, body: string): string { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, `#!/bin/sh\n${body}\n`); + chmodSync(path, 0o755); + return path; +} + +/** A launcher file (made with its folders). */ +function launcher(path: string, keys: Record): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, ['[Desktop Entry]', ...Object.entries(keys).map(([k, v]) => `${k}=${v}`), '', '[Desktop Action new-window]', 'Name=New Window', 'Exec=ignored'].join('\n')); +} + +describe('Linux launchers', () => { + it('reads the entry group only, without localized keys, and unescapes values', () => { + const keys = parseDesktopEntry('# comment\n[Desktop Entry]\nName=Fire\\sfox\nName[de]=Feuerfuchs\nExec=firefox %u\n\n[Desktop Action private]\nName=Private\nExec=firefox --private-window %u\n'); + expect(keys).toEqual({ Name: 'Fire fox', Exec: 'firefox %u' }); + }); + + it('splits Exec as the spec quotes it', () => { + expect(splitExec('"/opt/My Browser/browser" --flag "a \\"quoted\\" \\$word" %U')).toEqual(['/opt/My Browser/browser', '--flag', 'a "quoted" $word', '%U']); + expect(splitExec(' env A=1 firefox\t%u ')).toEqual(['env', 'A=1', 'firefox', '%u']); + expect(splitExec('browser ""')).toEqual(['browser', '']); + }); + + it('puts the address where the first URL field code was, drops the other codes, and keeps a literal percent', () => { + expect(commandOf(['chrome', '--profile', '%U', '%i', '%c'])).toEqual({ command: ['chrome', '--profile'], urlAt: 2 }); + expect(commandOf(['flatpak', 'run', 'org.mozilla.firefox', '@@u', '%u', '@@'])).toEqual({ command: ['flatpak', 'run', 'org.mozilla.firefox', '@@u', '@@'], urlAt: 4 }); + expect(commandOf(['browser', '--zoom=100%%'])).toEqual({ command: ['browser', '--zoom=100%'], urlAt: 2 }); + expect(commandOf(['a', '%u', '%U'])).toEqual({ command: ['a'], urlAt: 1 }); + }); + + it('knows the program past env and its assignments', () => { + expect(programOf(['env', 'BAMF=1', 'X_Y=2', '/snap/bin/firefox', '--new'])).toBe('/snap/bin/firefox'); + expect(programOf(['google-chrome-stable'])).toBe('google-chrome-stable'); + expect(programOf(['env'])).toBeNull(); + }); + + it('offers browsers: the category, or web links with HTML; not hidden entries, terminals or the app itself', () => { + const base = { Type: 'Application', Name: 'B', Exec: 'b %u' }; + expect(isBrowserEntry({ ...base, Categories: 'Network;WebBrowser;' }, 'b.desktop')).toBe(true); + expect(isBrowserEntry({ ...base, MimeType: 'text/html;x-scheme-handler/https;' }, 'b.desktop')).toBe(true); + expect(isBrowserEntry({ ...base, MimeType: 'x-scheme-handler/https;' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;', NoDisplay: 'true' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;', Terminal: 'true' }, 'b.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Categories: 'WebBrowser;' }, 'console-editor.desktop')).toBe(false); + expect(isBrowserEntry({ ...base, Type: 'Link', Categories: 'WebBrowser;' }, 'b.desktop')).toBe(false); + }); + + describe('finding them', () => { + const env = { ...process.env }; + const home = join(tmp, 'linux-home'); + const system = join(tmp, 'linux-system'); + const bin = join(tmp, 'linux-bin'); + + beforeEach(() => { + process.env.XDG_DATA_HOME = home; + process.env.XDG_DATA_DIRS = system; + script(join(bin, 'fake-chrome'), 'exit 0'); + script(join(bin, 'fake-gecko'), 'exit 0'); + launcher(join(system, 'applications/fake-chrome.desktop'), { Type: 'Application', Name: 'Fake Chrome', Exec: `${bin}/fake-chrome --flag %U`, Icon: 'fake-chrome', Categories: 'Network;WebBrowser;' }); + launcher(join(system, 'applications/vendor/fake-gecko.desktop'), { Type: 'Application', Name: 'Fake Firefox', Exec: `${bin}/fake-gecko %u`, Icon: `${system}/abs.svg`, MimeType: 'text/html;x-scheme-handler/http;' }); + // The user's entry of the same id replaces the system's, browser or not. + launcher(join(system, 'applications/replaced.desktop'), { Type: 'Application', Name: 'Replaced', Exec: `${bin}/fake-chrome`, Categories: 'WebBrowser;' }); + launcher(join(home, 'applications/replaced.desktop'), { Type: 'Application', Name: 'Replaced', Exec: `${bin}/fake-chrome`, NoDisplay: 'true', Categories: 'WebBrowser;' }); + // Uninstalled: its program is gone. + launcher(join(system, 'applications/gone.desktop'), { Type: 'Application', Name: 'Gone', Exec: `${bin}/nothing-here %u`, Categories: 'WebBrowser;' }); + launcher(join(system, 'applications/editor.desktop'), { Type: 'Application', Name: 'Editor', Exec: `${bin}/fake-chrome`, Categories: 'Development;' }); + mkdirSync(join(system, 'icons/hicolor/48x48/apps'), { recursive: true }); + mkdirSync(join(system, 'icons/hicolor/128x128/apps'), { recursive: true }); + writeFileSync(join(system, 'icons/hicolor/48x48/apps/fake-chrome.png'), 'small'); + writeFileSync(join(system, 'icons/hicolor/128x128/apps/fake-chrome.png'), 'large'); + writeFileSync(join(system, 'abs.svg'), ''); + }); + afterEach(() => { + process.env.XDG_DATA_HOME = env.XDG_DATA_HOME; + process.env.XDG_DATA_DIRS = env.XDG_DATA_DIRS; + if (env.XDG_DATA_HOME === undefined) delete process.env.XDG_DATA_HOME; + if (env.XDG_DATA_DIRS === undefined) delete process.env.XDG_DATA_DIRS; + }); + + it('lists the browsers whose program is there, with their engine, command and best icon', async () => { + // Flatpak's and Snap's exports are looked in too: only this test's launchers are compared. + const found = (await findLinuxBrowsers()).filter((b) => b.program?.startsWith(bin)); + expect(found).toEqual([ + { + id: 'desktop:fake-chrome.desktop', + name: 'Fake Chrome', + engine: 'chromium', + command: [`${bin}/fake-chrome`, '--flag'], + urlAt: 2, + iconFile: join(system, 'icons/hicolor/128x128/apps/fake-chrome.png'), + app: null, + program: `${bin}/fake-chrome`, + added: false, + }, + expect.objectContaining({ id: 'desktop:vendor-fake-gecko.desktop', name: 'Fake Firefox', engine: 'gecko', iconFile: join(system, 'abs.svg'), urlAt: 1 }), + ]); + }); + }); + + it('tells two browsers of one name apart by where they came from', () => { + const b = (name: string) => ({ id: name, name, engine: 'gecko', command: [], urlAt: 0, iconFile: null, app: null, program: null, added: false }) as FoundBrowser; + const named = labelDuplicates([ + { browser: b('Firefox'), path: '/usr/share/applications/firefox.desktop' }, + { browser: b('Firefox'), path: '/var/lib/snapd/desktop/applications/firefox_firefox.desktop' }, + { browser: b('Firefox'), path: '/var/lib/flatpak/exports/share/applications/org.mozilla.firefox.desktop' }, + { browser: b('Chromium'), path: '/var/lib/snapd/desktop/applications/chromium.desktop' }, + ]).map((x) => x.name); + expect(named).toEqual(['Firefox', 'Firefox (Snap)', 'Firefox (Flatpak)', 'Chromium']); + }); +}); + +describe('Windows registry', () => { + const output = [ + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome', + ' (Default) REG_SZ Google Chrome', + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome\\shell\\open\\command', + ' (Default) REG_SZ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"', + '', + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Firefox-308046B0AF4A39CB', + ' (Default) REG_SZ Firefox', + ' Empty REG_SZ', + '', + ].join('\r\n'); + + it('reads keys and values, case aside', () => { + const keys = parseRegQuery(output); + const chrome = keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\google chrome\\shell\\open\\command'); + expect(chrome?.values.get('(default)')).toBe('"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"'); + expect(keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\firefox-308046b0af4a39cb')?.values.get('empty')).toBe(''); + expect(keys.get('hkey_local_machine\\software\\clients\\startmenuinternet\\google chrome')?.path).toBe('HKEY_LOCAL_MACHINE\\SOFTWARE\\Clients\\StartMenuInternet\\Google Chrome'); + }); + + it("takes a command's program, quoted or not", () => { + expect(programOfCommand('"C:\\Program Files\\Mozilla Firefox\\firefox.exe" -osint -url "%1"')).toBe('C:\\Program Files\\Mozilla Firefox\\firefox.exe'); + expect(programOfCommand('C:\\Browsers\\brave.exe --flag')).toBe('C:\\Browsers\\brave.exe'); + expect(programOfCommand('"unterminated')).toBeNull(); + expect(programOfCommand(' ')).toBeNull(); + }); +}); + +describe('Engines', () => { + it('tells engines from names, and knows when it can’t', () => { + expect(engineOf(['org.mozilla.firefox.desktop'])).toBe('gecko'); + expect(engineOf(['/usr/bin/librewolf'])).toBe('gecko'); + expect(engineOf(['Zen Browser'])).toBe('gecko'); + expect(engineOf(['Safari Technology Preview'])).toBe('webkit'); + expect(engineOf(['org.gnome.Epiphany.desktop'])).toBe('webkit'); + expect(engineOf(['C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'.replaceAll('\\', '/')])).toBe('chromium'); + expect(engineOf(['Arc'])).toBe('chromium'); + expect(engineOf(['Archive Utility'])).toBe('unknown'); + expect(engineOf([null, 'Brave Browser'])).toBe('chromium'); + expect(engineOf(['Lynx'])).toBe('unknown'); + }); + + it('starts browsers without the libraries an AppImage set for the app', () => { + const env = { APPIMAGE: '/x.AppImage', LD_LIBRARY_PATH: '/tmp/.mount/usr/lib', HOME: '/home/u' }; + expect(launchEnv(env)).toEqual({ APPIMAGE: '/x.AppImage', HOME: '/home/u' }); + const plain = { LD_LIBRARY_PATH: '/opt/lib' }; + expect(launchEnv(plain)).toBe(plain); + }); +}); + +describe('Added and hidden browsers', () => { + it('keeps them across a restart, and reads a malformed or newer file as none', async () => { + const path = join(tmp, 'prefs', 'browsers.json'); + const store = new BrowserStore(path); + await store.load(); + await store.add({ id: 'added:0a1b2c3d', name: 'Nightly', path: '/opt/nightly/firefox', engine: 'gecko' }); + await store.setHidden('desktop:chromium.desktop', true); + await store.setHidden('added:0a1b2c3d', true); + await store.setHidden('added:0a1b2c3d', false); + + const again = new BrowserStore(path); + await again.load(); + expect(again.get()).toEqual({ added: [{ id: 'added:0a1b2c3d', name: 'Nightly', path: '/opt/nightly/firefox', engine: 'gecko' }], hidden: ['desktop:chromium.desktop'] }); + + await again.remove('added:0a1b2c3d'); + expect(again.get().added).toEqual([]); + + writeFileSync(path, JSON.stringify({ version: 2, added: [], hidden: ['x'] })); + await again.load(); + expect(again.get()).toEqual({ added: [], hidden: [] }); + writeFileSync(path, JSON.stringify({ version: 1, added: [{ id: 'nope', name: 'X', path: '/x', engine: 'gecko' }, { id: 'added:00000000', name: 'Y', path: '/y', engine: 'blink' }], hidden: [1, 'a', 'a'] })); + await again.load(); + expect(again.get()).toEqual({ added: [], hidden: ['a'] }); + }); +}); + +describe('The registry', () => { + const out = join(tmp, 'opened.txt'); + const fake = script(join(tmp, 'registry-bin', 'fake-browser'), `if [ "$1" = "--version" ]; then echo "Fake Browser 12.3.4"; exit 0; fi\necho "$@" > ${out}`); + const found: FoundBrowser = { id: 'desktop:fake.desktop', name: 'Fake', engine: 'chromium', command: [fake, '--new-window'], urlAt: 2, iconFile: null, app: null, program: fake, added: false }; + + async function registry() { + const prefs = new BrowserStore(join(mkdtempSync(join(tmp, 'registry-')), 'browsers.json')); + await prefs.load(); + const events: unknown[] = []; + const find = vi.fn(async () => [found]); + return { registry: new BrowserRegistry({ prefs, send: (e) => events.push(e), find, home: tmp }), events, find, prefs }; + } + + it('lists what it found, looks again only after a minute, and announces versions once read', async () => { + const { registry: r, events, find } = await registry(); + expect(await r.list()).toEqual([{ id: found.id, name: 'Fake', engine: 'chromium', version: null, icon: null, added: false, hidden: false, debuggable: false, listsTabs: false, build: null }]); + await r.list(); + expect(find).toHaveBeenCalledTimes(1); + await expect.poll(() => events.length).toBe(1); + expect(events[0]).toEqual({ type: 'browsers-changed', browsers: [expect.objectContaining({ id: found.id, version: '12.3.4' })] }); + }); + + it('opens an http(s) address with the browser’s command, and nothing else', async () => { + const { registry: r } = await registry(); + await r.open(found.id, 'https://shop.test/cart?x=1'); + await expect.poll(() => readFileSync(out, 'utf8').trim()).toBe('--new-window https://shop.test/cart?x=1'); + await expect(r.open(found.id, 'file:///etc/passwd')).rejects.toThrow('Only http(s)'); + await expect(r.open('desktop:unknown.desktop', 'https://shop.test/')).rejects.toThrow('no longer there'); + }); + + it('lists a build the app downloads (WebKit) with its version, whether downloaded, and opens it with changes only', async () => { + const prefs = new BrowserStore(join(mkdtempSync(join(tmp, 'registry-')), 'browsers.json')); + await prefs.load(); + const events: Array<{ type: string; browsers?: Array<{ id: string; build: unknown }> }> = []; + let downloaded = false; + const webkit: FoundBrowser = { id: 'playwright:webkit', name: 'WebKit', engine: 'webkit', command: [], urlAt: 0, iconFile: null, app: null, program: join(tmp, 'pw_run.sh'), added: false }; + const builds = { list: async () => [{ browser: webkit, version: '26.6', downloaded }] }; + const r = new BrowserRegistry({ prefs, send: (e) => events.push(e as (typeof events)[number]), find: async () => [found], home: tmp, builds }); + expect((await r.list()).find((b) => b.id === webkit.id)).toMatchObject({ name: 'WebKit', engine: 'webkit', version: '26.6', build: { downloaded: false } }); + await expect(r.open(webkit.id, 'https://shop.test/')).rejects.toThrow('WebKit opens with your changes only'); + downloaded = true; + await r.buildsChanged(); + expect(events.at(-1)?.browsers?.find((b) => b.id === webkit.id)).toMatchObject({ build: { downloaded: true } }); + expect(await r.get(webkit.id)).toEqual(webkit); + }); + + it('adds a program, hides and removes it, and refuses what can’t be run', async () => { + const { registry: r, events } = await registry(); + const added = await r.add(fake); + expect(added).toEqual({ id: expect.stringMatching(/^added:[0-9a-f]{8}$/), name: 'fake-browser', engine: 'unknown', version: null, icon: null, added: true, hidden: false, debuggable: false, listsTabs: false, build: null }); + await r.setHidden(added.id, true); + expect((await r.list()).find((b) => b.id === added.id)?.hidden).toBe(true); + await r.remove(added.id); + expect((await r.list()).map((b) => b.id)).toEqual([found.id]); + expect(events.filter((e) => (e as { type: string }).type === 'browsers-changed').length).toBeGreaterThanOrEqual(3); + await expect(r.remove(found.id)).rejects.toThrow('Only a browser you added'); + await expect(r.add(join(tmp, 'missing'))).rejects.toThrow("isn't there"); + const plain = join(tmp, 'not-executable'); + writeFileSync(plain, ''); + await expect(r.add(plain)).rejects.toThrow("can't be run"); + await expect(r.add(tmp)).rejects.toThrow("isn't a program"); + }); +}); + +describe('Driving a Chromium browser', () => { + const chrome = (command: string[], urlAt = command.length, extra: Partial = {}): FoundBrowser => ({ + id: 'desktop:google-chrome.desktop', + name: 'Google Chrome', + engine: 'chromium', + command, + urlAt, + iconFile: null, + app: null, + program: command[0], + added: false, + ...extra, + }); + const flags = ['--user-data-dir=/p', '--remote-debugging-port=0']; + + it('puts its flags and address where the browser takes them', () => { + expect(driveCommand(chrome(['/usr/bin/google-chrome', '--incognito']), flags, 'about:blank')).toEqual(['/usr/bin/google-chrome', '--incognito', ...flags, 'about:blank']); + // A launcher's address in the middle stays in the middle. + expect(driveCommand(chrome(['/usr/bin/chrome', '--x'], 1), flags, 'about:blank')).toEqual(['/usr/bin/chrome', ...flags, 'about:blank', '--x']); + // Flatpak: before its forwarded-file markers, which go. + expect(driveCommand(chrome(['/usr/bin/flatpak', 'run', 'org.chromium.Chromium', '@@u', '@@'], 3), flags, 'about:blank')).toEqual(['/usr/bin/flatpak', 'run', 'org.chromium.Chromium', ...flags, 'about:blank']); + // macOS: a new instance of the app, the flags after --args. + expect(driveCommand(chrome(['open', '-a', '/Applications/Google Chrome.app']), flags, 'about:blank')).toEqual(['open', '-n', '-a', '/Applications/Google Chrome.app', '--args', ...flags, 'about:blank']); + }); + + it("keeps its profile in the app's data, or where a Snap's or Flatpak's sandbox lets it write", () => { + expect(profileDir(chrome(['/usr/bin/google-chrome']), '/data')).toBe('/data/browsers/desktop_google-chrome.desktop'); + expect(profileDir(chrome(['/snap/bin/chromium']), '/data')).toMatch(/\/snap\/chromium\/common\/console-editor-profile$/); + expect(profileDir(chrome(['/usr/bin/flatpak', 'run', '--branch=stable', 'org.chromium.Chromium'], 4), '/data')).toMatch(/\/\.var\/app\/org\.chromium\.Chromium\/data\/console-editor-profile$/); + }); + + it('reads the address of its debugging port from its profile, once written', async () => { + const dir = join(tmp, 'profile'); + mkdirSync(dir, { recursive: true }); + expect(await readActivePort(dir)).toBeNull(); + writeFileSync(join(dir, 'DevToolsActivePort'), '9333\n/devtools/browser/abc\n'); + expect(await readActivePort(dir)).toBe('ws://127.0.0.1:9333/devtools/browser/abc'); + writeFileSync(join(dir, 'DevToolsActivePort'), '9333'); + expect(await readActivePort(dir)).toBeNull(); + }); + + it('keeps its tabs: their changes, the one showing an address, a blank one to reuse, and the one a new target becomes', async () => { + const tabs = new DrivenTabs(); + const tab = (id: string, url: string) => ({ info: { id, title: '', url }, sessionId: `s-${id}` }) as DrivenTabState; + tabs.add(tab('a', 'about:blank')); + expect(tabs.blank()?.info.id).toBe('a'); + expect(tabs.update('a', { url: 'https://shop.test/', title: 'Shop' })).toBe(true); + expect(tabs.update('a', { url: 'https://shop.test/', title: 'Shop' })).toBe(false); + expect(tabs.update('a', { title: 'Shop' })).toBe(false); + expect(tabs.update('gone', { url: 'x', title: 'y' })).toBe(false); + expect(tabs.blank()).toBeUndefined(); + const arriving = tabs.arrival('b'); + tabs.add(tab('b', 'about:blank')); + expect((await arriving).info.id).toBe('b'); + expect(tabs.list()).toEqual([{ id: 'a', title: 'Shop', url: 'https://shop.test/' }, { id: 'b', title: '', url: 'about:blank' }]); + expect(tabs.showing('https://shop.test/')?.info.id).toBe('a'); + expect(tabs.remove('a')?.info.id).toBe('a'); + expect(() => tabs.get('a')).toThrow('That tab is closed'); + expect(tabs.clear().map((t) => t.info.id)).toEqual(['b']); + }); +}); + +describe("Your everyday Chromium browser's profile", () => { + const found = (name: string, command: string[], id = 'desktop:x.desktop'): FoundBrowser => ({ id, name, engine: 'chromium', command, urlAt: command.length, iconFile: null, app: null, program: command[0], added: false }); + + it("is where each system keeps it, by the browser's name or program", () => { + expect(everydayProfileDir(found('Google Chrome', ['/usr/bin/google-chrome-stable']), '/home/me', 'linux')).toBe('/home/me/.config/google-chrome'); + expect(everydayProfileDir(found('Google Chrome (beta)', ['/usr/bin/google-chrome-beta']), '/home/me', 'linux')).toBe('/home/me/.config/google-chrome-beta'); + expect(everydayProfileDir(found('Brave Web Browser', ['/usr/bin/brave-browser']), '/home/me', 'linux')).toBe('/home/me/.config/BraveSoftware/Brave-Browser'); + expect(everydayProfileDir(found('Microsoft Edge', ['open', '-a', '/Applications/Microsoft Edge.app']), '/Users/me', 'darwin')).toBe('/Users/me/Library/Application Support/Microsoft Edge'); + expect(everydayProfileDir(found('Google Chrome', ['C:/Program Files/Google/Chrome/Application/chrome.exe']), 'C:/Users/me', 'win32')).toMatch(/AppData.Local.Google.Chrome.User Data$/); + expect(everydayProfileDir(found('Unknown Browser', ['/usr/bin/unknown']), '/home/me', 'linux')).toBeNull(); + }); + + it("is in a Snap's or a Flatpak's own folder", () => { + expect(everydayProfileDir(found('Chromium', ['/snap/bin/chromium']), '/home/me', 'linux')).toBe('/home/me/snap/chromium/common/chromium'); + expect(everydayProfileDir(found('Google Chrome', ['/usr/bin/flatpak', 'run', '--branch=stable', 'com.google.Chrome']), '/home/me', 'linux')).toBe('/home/me/.var/app/com.google.Chrome/config/google-chrome'); + }); + + it('counts as running when its debugging port takes connections', async () => { + const { createServer } = await import('node:net'); + const server = createServer((socket) => socket.end()); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as import('node:net').AddressInfo; + expect(await isListening(port)).toBe(true); + await new Promise((r) => server.close(r)); + expect(await isListening(port)).toBe(false); + }); +}); diff --git a/test/unit/everydayTabs.test.ts b/test/unit/everydayTabs.test.ts new file mode 100644 index 00000000..7f76a835 --- /dev/null +++ b/test/unit/everydayTabs.test.ts @@ -0,0 +1,158 @@ +/** + * Your everyday Firefox's tabs: its compressed session file (`mozLz40`, an LZ4 block) read, including one Firefox + * wrote, its profiles found from profiles.ini (the install's default first), and the page each tab shows, on the web. + * And your macOS browsers' tabs through scripting: the script run against a stand-in for JavaScript for Automation. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import { listEverydayTabs, listsTabs } from '../../src/main/browsers/everyday'; +import { scriptableApp } from '../../src/main/browsers/everyday/scriptable'; +import { scriptTabsOf } from '../../src/main/browsers/everyday/scriptable/scriptTabsOf'; +import { tabsScript } from '../../src/main/browsers/everyday/scriptable/tabsScript'; +import type { FoundBrowser } from '../../src/main/browsers/types'; +import { lz4Block } from '../../src/main/browsers/everyday/lz4Block'; +import { profilesOf } from '../../src/main/browsers/everyday/profilesOf'; +import { readMozLz4 } from '../../src/main/browsers/everyday/readMozLz4'; +import { sessionTabsOf } from '../../src/main/browsers/everyday/sessionTabsOf'; +import { mozLz4 } from '../helpers/mozLz4'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-everyday-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const session = { + windows: [ + { tabs: [{ index: 2, entries: [{ url: 'https://old.test/' }, { url: 'https://shop.test/cart', title: 'Cart' }] }, { index: 1, entries: [{ url: 'about:preferences' }] }] }, + { tabs: [{ entries: [{ url: 'http://localhost:3000/', title: '' }] }] }, + ], +}; + +describe('A Firefox session file', () => { + it('is decompressed: one Firefox wrote (matches and all), and one of literals only', async () => { + const real = JSON.parse((await readMozLz4(join(__dirname, '../fixtures/firefox/recovery.jsonlz4')))!) as { windows: unknown[] }; + expect(real.windows).toHaveLength(1); + const path = join(tmp, 'plain.jsonlz4'); + writeFileSync(path, mozLz4(session)); + expect(JSON.parse((await readMozLz4(path))!)).toEqual(session); + }); + + it("is refused when it isn't one: no header, or a block pointing before its start", async () => { + const path = join(tmp, 'bad.jsonlz4'); + writeFileSync(path, 'not a session'); + expect(await readMozLz4(path)).toBeNull(); + expect(await readMozLz4(join(tmp, 'missing.jsonlz4'))).toBeNull(); + // A literal, then a match 9 bytes back where only 1 was written. + expect(() => lz4Block(Uint8Array.from([0x10, 0x41, 0x09, 0x00]), 10)).toThrow('Not an LZ4 block'); + // An overlapping match repeats the byte before it. + expect(lz4Block(Uint8Array.from([0x10, 0x41, 0x01, 0x00, 0x00]), 10).toString()).toBe('AAAAA'); + }); + + it('lists the page each tab shows, on the web only, titled by its address when it has no title', () => { + expect(sessionTabsOf(JSON.stringify(session))).toEqual([ + { url: 'https://shop.test/cart', title: 'Cart' }, + { url: 'http://localhost:3000/', title: 'http://localhost:3000/' }, + ]); + expect(sessionTabsOf('{')).toEqual([]); + }); +}); + +describe("Firefox's profiles", () => { + it("are listed from profiles.ini, the install's default first, relative ones in the data folder", () => { + const ini = ['[General]', 'Version=2', '', '[Profile1]', 'Name=work', 'IsRelative=1', 'Path=Profiles/w.work', '', '[Profile0]', 'Name=default-release', 'IsRelative=1', 'Path=Profiles/d.default-release', '', '[Profile2]', 'Name=elsewhere', 'IsRelative=0', 'Path=/data/ff', '', '[Install4F96D1932A9F858E]', 'Default=Profiles/d.default-release'].join('\n'); + expect(profilesOf('/home/me/.mozilla/firefox', ini)).toEqual([ + { name: 'default-release', dir: '/home/me/.mozilla/firefox/Profiles/d.default-release' }, + { name: 'work', dir: '/home/me/.mozilla/firefox/Profiles/w.work' }, + { name: 'elsewhere', dir: '/data/ff' }, + ]); + }); + + it("give the tabs of each profile with a session, from the system's own Firefox folder", async () => { + const home = join(tmp, 'home'); + const data = join(home, '.mozilla/firefox'); + mkdirSync(join(data, 'Profiles/d.default/sessionstore-backups'), { recursive: true }); + mkdirSync(join(data, 'Profiles/e.empty'), { recursive: true }); + writeFileSync(join(data, 'profiles.ini'), '[Profile0]\nName=default\nIsRelative=1\nPath=Profiles/d.default\n[Profile1]\nName=empty\nIsRelative=1\nPath=Profiles/e.empty\n'); + writeFileSync(join(data, 'Profiles/d.default/sessionstore-backups/recovery.jsonlz4'), mozLz4(session)); + expect(await listEverydayTabs([], home, 'linux')).toEqual([{ id: join(data, 'Profiles/d.default'), name: 'Firefox', profile: 'default', tabs: sessionTabsOf(JSON.stringify(session)) }]); + expect(await listEverydayTabs([], home, 'aix')).toEqual([]); + }); +}); + +describe("Your macOS browsers' tabs, through scripting (JavaScript for Automation)", () => { + const mac = (name: string, engine: FoundBrowser['engine'] = 'chromium'): FoundBrowser => ({ id: `mac:${name}`, name, engine, command: ['open', '-a', `/Applications/${name}.app`], urlAt: 3, iconFile: null, app: `/Applications/${name}.app`, program: null, added: false }); + const [safari, chrome, arc, opera, firefox] = [mac('Safari', 'webkit'), mac('Google Chrome'), mac('Arc'), mac('Opera'), mac('Firefox', 'gecko')]; + + /** + * A stand-in for JXA's `Application`: running apps' windows, each a tab list whose properties are read at once + * (`w.tabs.url()` gives every tab's address), a window without tabs throwing as Safari's settings window does. + */ + const automation = (apps: Record | null>; refuse?: string }>) => (name: string) => { + const app = apps[name]; + if (!app) throw new Error(`Can't get application "${name}"`); + return { + running: () => app.running, + windows: () => { + if (app.refuse) throw new Error(app.refuse); + return (app.windows ?? []).map((tabs) => ({ + tabs: new Proxy({}, { get: (_, key: string) => () => { + if (!tabs) throw new Error("Can't get tabs of window (-1728)"); + return tabs.map((t) => (key === 'url' ? t.url : (t as Record)[key === 'name' ? 'title' : key])); + } }), + })); + }, + }; + }; + const runScript = (script: string, apps: Parameters[0]) => String(runInNewContext(script, { Application: automation(apps) })); + + it('are asked of the browsers scripting reaches, by the name of their app', () => { + expect([safari, chrome, arc, opera, firefox].map(scriptableApp)).toEqual(['Safari', 'Google Chrome', 'Arc', null, null]); + expect(scriptableApp({ ...chrome, app: null })).toBeNull(); + expect([safari, chrome, opera, firefox].map((b) => listsTabs(b, 'darwin'))).toEqual([true, true, false, true]); + expect([safari, chrome, firefox].map((b) => listsTabs(b, 'linux'))).toEqual([false, false, true]); + }); + + it("are read by a script of running apps' windows (Safari's titles are names), skipping a window without tabs", () => { + const output = runScript(tabsScript(['Safari', 'Google Chrome', 'Arc']), { + Safari: { running: true, windows: [[{ url: 'https://shop.test/', title: 'Shop' }], null] }, + 'Google Chrome': { running: true, windows: [[{ url: 'https://a.test/', title: 'A' }, { url: 'chrome://settings/', title: 'Settings' }], [{ url: 'http://localhost:3000/', title: '' }]] }, + Arc: { running: false }, + }); + expect(JSON.parse(output)).toEqual([ + { name: 'Safari', running: true, tabs: [['https://shop.test/', 'Shop']] }, + { name: 'Google Chrome', running: true, tabs: [['https://a.test/', 'A'], ['chrome://settings/', 'Settings'], ['http://localhost:3000/', '']] }, + { name: 'Arc', running: false, tabs: [] }, + ]); + }); + + it("give running browsers' pages on the web, titled by their address when untitled, and why one couldn't be read", () => { + const ids = new Map([['Safari', 'mac:Safari'], ['Google Chrome', 'mac:Google Chrome'], ['Arc', 'mac:Arc']]); + const output = runScript(tabsScript(['Safari', 'Google Chrome', 'Arc']), { + Safari: { running: true, refuse: 'Error: Not authorized to send Apple events to Safari. (-1743)' }, + 'Google Chrome': { running: true, windows: [[{ url: 'https://a.test/', title: 'A' }, { url: 'chrome://settings/', title: 'Settings' }, { url: 'http://localhost:3000/', title: '' }]] }, + Arc: { running: false }, + }); + expect(scriptTabsOf(output, ids)).toEqual([ + { id: 'mac:Safari', name: 'Safari', profile: null, tabs: [], problem: 'Allow Console Editor to control Safari in System Settings › Privacy & Security › Automation' }, + { id: 'mac:Google Chrome', name: 'Google Chrome', profile: null, tabs: [{ url: 'https://a.test/', title: 'A' }, { url: 'http://localhost:3000/', title: 'http://localhost:3000/' }] }, + ]); + expect(scriptTabsOf('[{"name":"Arc","running":true,"tabs":[],"error":"Error: timed out"}]', ids)).toEqual([{ id: 'mac:Arc', name: 'Arc', profile: null, tabs: [], problem: 'Could not read its tabs: Error: timed out' }]); + expect(scriptTabsOf('not json', ids)).toEqual([]); + expect(scriptTabsOf('[{"name":"Unknown","running":true,"tabs":[]}]', ids)).toEqual([]); + }); + + it('are read on macOS only, in one script for every browser that has them, beside Firefox', async () => { + const calls: Array<[string, string[]]> = []; + const run = async (file: string, args: string[]) => { + calls.push([file, args]); + return runScript(args.at(-1)!, { Safari: { running: true, windows: [[{ url: 'https://shop.test/', title: 'Shop' }]] }, 'Google Chrome': { running: false } }); + }; + const home = join(tmp, 'mac-home'); + expect(await listEverydayTabs([safari, chrome, opera, firefox], home, 'darwin', run)).toEqual([{ id: 'mac:Safari', name: 'Safari', profile: null, tabs: [{ url: 'https://shop.test/', title: 'Shop' }] }]); + expect(calls.map(([file, args]) => [file, ...args.slice(0, -1)])).toEqual([['osascript', '-l', 'JavaScript', '-e']]); + expect(await listEverydayTabs([opera, firefox], home, 'darwin', run)).toEqual([]); + expect(await listEverydayTabs([safari, chrome], home, 'linux', run)).toEqual([]); + expect(calls).toHaveLength(1); + }); +}); diff --git a/test/unit/figma.test.ts b/test/unit/figma.test.ts new file mode 100644 index 00000000..b7288def --- /dev/null +++ b/test/unit/figma.test.ts @@ -0,0 +1,142 @@ +/** + * Figma frames brought in as designs: the frame a link names, the form's schema, the token kept encrypted (and not + * where the system can't encrypt it), and the import against a stand-in for Figma's API: the frame's name, its 2× + * render downloaded without the token, a token kept only once it worked, and Figma's refusals in words. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { FigmaImporter } from '../../src/main/figma'; +import { FigmaToken } from '../../src/main/figma/FigmaToken'; +import type { TokenCrypt } from '../../src/main/figma/types'; +import { figmaFrameOf, figmaImportSchema } from '../../src/shared/figma'; +import { firstIssue } from '../../src/shared/validation'; +import type { Shot } from '../../src/shared/types'; +import { encodePng } from '../helpers/encodePng'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-figma-')); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +/** A stand-in for the system's encryption: reversible, and not the plain text. */ +const crypt = (backend = 'gnome_libsecret'): TokenCrypt => ({ + isEncryptionAvailable: () => true, + encryptString: (text) => Buffer.from(`enc:${Buffer.from(text).toString('base64')}`), + decryptString: (bytes) => Buffer.from(bytes.toString().slice(4), 'base64').toString(), + getSelectedStorageBackend: () => backend, +}); + +describe('A Figma link', () => { + it('names a frame: its file (a branch\'s own) and node, as the API writes it', () => { + expect(figmaFrameOf('https://www.figma.com/design/AbC123/Shop?node-id=12-34&t=x')).toEqual({ fileKey: 'AbC123', nodeId: '12:34' }); + expect(figmaFrameOf(' https://figma.com/file/AbC123/Shop?node-id=1%3A2 ')).toEqual({ fileKey: 'AbC123', nodeId: '1:2' }); + expect(figmaFrameOf('https://www.figma.com/proto/AbC123/Shop?node-id=5-6')).toEqual({ fileKey: 'AbC123', nodeId: '5:6' }); + expect(figmaFrameOf('https://www.figma.com/design/AbC123/branch/Br456/Shop?node-id=7-8')).toEqual({ fileKey: 'Br456', nodeId: '7:8' }); + }); + + it("names none without a frame, elsewhere, or when it isn't a link", () => { + expect(figmaFrameOf('https://www.figma.com/design/AbC123/Shop')).toBeNull(); + expect(figmaFrameOf('https://evil.test/design/AbC123/Shop?node-id=1-2')).toBeNull(); + expect(figmaFrameOf('https://www.figma.com/community/file/1?node-id=1-2')).toBeNull(); + expect(figmaFrameOf('figma frame')).toBeNull(); + }); + + it('is checked with the token by one schema, the form\'s and the main process\'s', () => { + expect(figmaImportSchema.parse({ link: ' https://www.figma.com/design/A/S?node-id=1-2 ', token: ' figd_x ', extra: 1 })).toEqual({ link: 'https://www.figma.com/design/A/S?node-id=1-2', token: 'figd_x' }); + expect(firstIssue(figmaImportSchema, { link: 'https://www.figma.com/design/A/S', token: '' })).toBe('Paste the link to a frame: select it in Figma, then Share › Copy link'); + expect(firstIssue(figmaImportSchema, { link: 'https://www.figma.com/design/A/S?node-id=1-2', token: 'x'.repeat(201) })).toBe("That isn't a Figma token"); + }); +}); + +describe('The Figma token', () => { + it('is kept encrypted in the data folder, read back by the next run, and forgotten', async () => { + const file = join(tmp, 'token'); + await new FigmaToken(file, crypt()).set('figd_secret'); + expect(readFileSync(file, 'utf8')).not.toContain('figd_secret'); + const next = new FigmaToken(file, crypt()); + expect(await next.get()).toBe('figd_secret'); + await next.clear(); + expect(existsSync(file)).toBe(false); + expect(await new FigmaToken(file, crypt()).get()).toBeNull(); + }); + + it("is kept for this run only where the system can't encrypt it, and unread when it can't be decrypted", async () => { + const file = join(tmp, 'plain'); + const unencrypted = process.platform === 'linux' ? crypt('basic_text') : { ...crypt(), isEncryptionAvailable: () => false }; + const token = new FigmaToken(file, unencrypted); + await token.set('figd_secret'); + expect(await token.get()).toBe('figd_secret'); + expect(existsSync(file)).toBe(false); + await new FigmaToken(file, crypt()).set('figd_secret'); + expect(await new FigmaToken(file, { ...crypt(), decryptString: () => { throw new Error('bad key'); } }).get()).toBeNull(); + }); +}); + +describe('Importing a Figma frame', () => { + let server: Server; + let api: string; + const asked: Array<{ path: string; token: string | undefined }> = []; + const added: Array<{ name: string; bytes: Uint8Array }> = []; + const png = encodePng(8, 6, () => [0, 128, 255]); + const answers: Record [number, unknown]> = { + '/v1/files/AbC/nodes': () => [200, { nodes: { '12:34': { document: { name: 'Home / Desktop' } } } }], + '/v1/images/AbC': () => [200, { err: null, images: { '12:34': `${api}/render/home.png` } }], + '/v1/files/Gone/nodes': () => [404, { status: 404, err: 'Not found' }], + '/v1/files/Blank/nodes': () => [200, { nodes: { '1:2': { document: { name: 'Blank' } } } }], + '/v1/images/Blank': () => [200, { err: null, images: { '1:2': null } }], + }; + const importer = (tokenFile: string) => new FigmaImporter({ tokenFile, api, crypt: crypt(), addDesign: async (name, bytes) => (added.push({ name, bytes }), { id: 's1', name } as Shot) }); + const link = (key: string, node = '12-34') => `https://www.figma.com/design/${key}/Shop?node-id=${node}`; + + beforeAll(async () => { + server = createServer((req, res) => { + const { pathname } = new URL(req.url!, 'http://x'); + asked.push({ path: req.url!, token: req.headers['x-figma-token'] as string | undefined }); + if (pathname.startsWith('/render/')) return void res.writeHead(200, { 'content-type': 'image/png' }).end(png); + if (req.headers['x-figma-token'] !== 'figd_good') return void res.writeHead(403, { 'content-type': 'application/json' }).end('{"status":403,"err":"Invalid token"}'); + const answer = answers[pathname]; + if (!answer) return void res.writeHead(404).end(); + const [status, body] = answer(); + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body)); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + api = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(() => new Promise((r) => server.close(r))); + + it("keeps the frame's 2× render as a design named after it, downloaded without the token, and then the token", async () => { + const figma = importer(join(tmp, 'import-token')); + expect(await figma.hasToken()).toBe(false); + await expect(figma.import({ link: link('AbC'), token: '' })).rejects.toThrow('Figma needs a personal access token'); + expect(await figma.import({ link: link('AbC'), token: 'figd_good' })).toMatchObject({ id: 's1' }); + expect(added.at(-1)!.name).toBe('Home Desktop@2x.png'); + expect(Buffer.from(added.at(-1)!.bytes).equals(png)).toBe(true); + expect(asked.map((a) => [a.path.split('?')[0], a.token])).toEqual([ + ['/v1/files/AbC/nodes', 'figd_good'], + ['/v1/images/AbC', 'figd_good'], + ['/render/home.png', undefined], + ]); + expect(asked[1].path).toContain('ids=12%3A34&format=png&scale=2'); + // Kept: the next import needs none. + expect(await figma.hasToken()).toBe(true); + await figma.import({ link: link('AbC'), token: '' }); + expect(added).toHaveLength(2); + await figma.forgetToken(); + expect(await figma.hasToken()).toBe(false); + }); + + it("says what Figma refused, and keeps no token that didn't work", async () => { + const figma = importer(join(tmp, 'refused-token')); + await expect(figma.import({ link: link('AbC'), token: 'figd_bad' })).rejects.toThrow('Figma refused the token'); + expect(await figma.hasToken()).toBe(false); + await expect(figma.import({ link: link('Gone'), token: 'figd_good' })).rejects.toThrow("Figma has no such file, or your token can't open it"); + await expect(figma.import({ link: link('AbC', '99-1'), token: 'figd_good' })).rejects.toThrow("That frame isn't in the file"); + await expect(figma.import({ link: link('Blank', '1-2'), token: 'figd_good' })).rejects.toThrow("Figma couldn't render that frame"); + await expect(figma.import({ link: 'https://www.figma.com/design/AbC/Shop', token: 'figd_good' })).rejects.toThrow('Paste the link to a frame'); + const offline = new FigmaImporter({ tokenFile: join(tmp, 'offline'), api: 'http://127.0.0.1:1', crypt: crypt(), addDesign: async () => ({}) as Shot }); + await expect(offline.import({ link: link('AbC'), token: 'figd_good' })).rejects.toThrow('Could not reach Figma'); + }); +}); diff --git a/test/unit/shots.test.ts b/test/unit/shots.test.ts new file mode 100644 index 00000000..812a5076 --- /dev/null +++ b/test/unit/shots.test.ts @@ -0,0 +1,210 @@ +/** + * Captures and designs: keeping them with their workspace (names, limits, renames, scale, deleting a workspace's, + * a malformed file), what a capture is named and which part of the page it clips to, and a PNG's size. + */ +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { ShotStore, type NewShot } from '../../src/main/store/ShotStore'; +import { captureName } from '../../src/main/shots/captureName'; +import { encodePng } from '../helpers/encodePng'; +import { captureInParts } from '../../src/main/shots/capture/captureInParts'; +import { clipOf } from '../../src/main/shots/capture/clipOf'; +import { partHeight } from '../../src/main/shots/capture/partHeight'; +import { partsOf } from '../../src/main/shots/capture/partsOf'; +import { readPngSize } from '../../src/main/shots/readPngSize'; + +const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'console-editor-shots-'))); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +/** A PNG header claiming `width` × `height` (enough for its size to be read). */ +function pngHeader(width: number, height: number): Buffer { + const bytes = Buffer.alloc(33); + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes); + bytes.writeUInt32BE(13, 8); + bytes.write('IHDR', 12); + bytes.writeUInt32BE(width, 16); + bytes.writeUInt32BE(height, 20); + return bytes; +} + +const capture = (name: string, extra: Partial = {}): NewShot => ({ + kind: 'capture', + name, + width: 2880, + height: 1800, + scale: 2, + pageUrl: 'https://shop.test/cart', + browser: { id: 'app', name: 'Chromium', version: '152.0.1' }, + viewport: { width: 1440, height: 900 }, + area: 'viewport', + group: null, + bytes: pngHeader(2880, 1800), + ext: 'png', + thumb: Buffer.from('thumb'), + ...extra, +}); + +async function openStore(dir = mkdtempSync(join(tmp, 'store-'))): Promise { + const store = new ShotStore(dir); + await store.load(); + store.setWorkspace('ws1'); + return store; +} + +describe('ShotStore', () => { + it('keeps a shot with its image and thumbnail, lists the active workspace’s newest first, and keeps them across a restart', async () => { + const dir = mkdtempSync(join(tmp, 'store-')); + const store = await openStore(dir); + const first = await store.add(capture('shop.test-cart-1440.png')); + await new Promise((r) => setTimeout(r, 5)); + const second = await store.add(capture('shop.test-cart-1440.png', { area: 'page' })); + // A name already in the workspace gets a number. + expect(second.name).toBe('shop.test-cart-1440-2.png'); + expect(first).toEqual({ id: expect.stringMatching(/^[0-9a-f]{8}$/), kind: 'capture', name: 'shop.test-cart-1440.png', width: 2880, height: 1800, scale: 2, pageUrl: 'https://shop.test/cart', browser: { id: 'app', name: 'Chromium', version: '152.0.1' }, viewport: { width: 1440, height: 900 }, area: 'viewport', group: null, createdAt: expect.any(Number), updatedAt: expect.any(Number) }); + expect(store.list().map((s) => s.id)).toEqual([second.id, first.id]); + expect(readFileSync(store.paths(first.id).thumb, 'utf8')).toBe('thumb'); + expect((await store.read(first.id)).equals(pngHeader(2880, 1800))).toBe(true); + + store.setWorkspace('ws2'); + expect(store.list()).toEqual([]); + // Any workspace's shot is still reached by id. + expect(store.get(first.id).name).toBe('shop.test-cart-1440.png'); + await store.add(capture('shop.test-cart-1440.png')); + expect(store.list()[0].name).toBe('shop.test-cart-1440.png'); + + const again = new ShotStore(dir); + await again.load(); + again.setWorkspace('ws1'); + expect(again.list().map((s) => s.name)).toEqual(['shop.test-cart-1440-2.png', 'shop.test-cart-1440.png']); + }); + + it('renames, refusing an empty name, one another has, and cleaning what file systems refuse', async () => { + const store = await openStore(); + const a = await store.add(capture('a.png')); + const b = await store.add(capture('b.png')); + expect((await store.rename(a.id, ' home: hero/v2?.png ')).name).toBe('home- hero-v2-.png'); + await expect(store.rename(a.id, ' ')).rejects.toThrow('Give it a name'); + await expect(store.rename(a.id, 'b.png')).rejects.toThrow('Another has the name b.png'); + await expect(store.rename('00000000', 'c.png')).rejects.toThrow('no longer exists'); + expect(store.get(b.id).name).toBe('b.png'); + expect((await store.setScale(a.id, 3)).scale).toBe(3); + await expect(store.setScale(a.id, 0)).rejects.toThrow('Invalid scale'); + }); + + it('deletes a shot with its files, and a workspace’s shots with theirs', async () => { + const store = await openStore(); + const a = await store.add(capture('a.png')); + const { image, thumb } = store.paths(a.id); + await store.remove(a.id); + expect(existsSync(image) || existsSync(thumb)).toBe(false); + expect(store.list()).toEqual([]); + + const b = await store.add(capture('b.png')); + store.setWorkspace('other'); + const c = await store.add(capture('c.png')); + const bFiles = store.paths(b.id); + await store.removeWorkspace('ws1'); + expect(existsSync(bFiles.image)).toBe(false); + expect(store.get(c.id).name).toBe('c.png'); + expect(() => store.get(b.id)).toThrow('no longer exists'); + }); + + it('refuses a file too large, and more shots than a workspace keeps', async () => { + const store = await openStore(); + await expect(store.add(capture('big.png', { bytes: Buffer.alloc(50 * 1024 * 1024 + 1) }))).rejects.toThrow('at most 50 MB'); + await Promise.all(Array.from({ length: 500 }, (_, i) => store.add(capture(`${i}.png`, { thumb: null })))); + await expect(store.add(capture('501.png'))).rejects.toThrow('at most 500'); + }); + + it('reads a malformed file or record as none, and a newer file as empty', async () => { + const dir = mkdtempSync(join(tmp, 'store-')); + const good = { id: '0a1b2c3d', workspaceId: 'ws1', kind: 'design', name: 'hero.png', width: 2880, height: 4000, scale: 2, ext: 'png', createdAt: 1, updatedAt: 1, pageUrl: null, browser: null, viewport: null, area: null, group: null }; + writeFileSync(join(dir, 'shots.json'), JSON.stringify({ version: 1, shots: [good, { ...good, id: 'bad' }, { ...good, id: '11111111', width: -1 }, { ...good, id: '22222222', kind: 'photo' }, good] })); + const store = await openStore(dir); + expect(store.list()).toEqual([{ id: '0a1b2c3d', kind: 'design', name: 'hero.png', width: 2880, height: 4000, scale: 2, createdAt: 1, updatedAt: 1, pageUrl: null, browser: null, viewport: null, area: null, group: null }]); + writeFileSync(join(dir, 'shots.json'), JSON.stringify({ version: 2, shots: [good] })); + await store.load(); + expect(store.list()).toEqual([]); + }); +}); + +describe('Capturing', () => { + it('names a capture after its page, width and what it covers', () => { + expect(captureName('https://shop.test/cart/items?x=1', 1440, 'page')).toBe('shop.test-cart-items-1440-full.png'); + expect(captureName('http://127.0.0.1:5173/', 390.4, 'viewport')).toBe('127.0.0.1-5173-390.png'); + expect(captureName('about:blank', 800, 'element')).toBe('page-800-element.png'); + expect(captureName('not a url', 800, 'viewport')).toBe('page-800.png'); + }); + + it('clips to the whole page (down to the height given) or to an element moved by the scroll, inside the page', () => { + const metrics = { cssLayoutViewport: { pageX: 0, pageY: 500, clientWidth: 1440, clientHeight: 900 }, cssContentSize: { width: 1440, height: 20_000 } }; + expect(clipOf({ area: 'viewport' }, metrics, 8192)).toBeNull(); + expect(clipOf({ area: 'page' }, metrics, 8192)).toEqual({ x: 0, y: 0, width: 1440, height: 8192 }); + expect(clipOf({ area: 'element', box: { x: 100, y: 50, width: 200, height: 80 } }, metrics, 8192)).toEqual({ x: 100, y: 550, width: 200, height: 80 }); + // Partly off the page's left edge: what is on the page. + expect(clipOf({ area: 'element', box: { x: -40, y: 0, width: 100, height: 10 } }, metrics, 8192)).toEqual({ x: 0, y: 500, width: 60, height: 10 }); + expect(() => clipOf({ area: 'element', box: { x: 10, y: 10, width: 0, height: 10 } }, metrics, 8192)).toThrow('no size'); + }); + + it('cuts a clip taller than a texture into parts that meet on a device pixel, the last one what is left', () => { + expect(partsOf({ x: 0, y: 100, width: 50, height: 10_000 }, 4096)).toEqual([ + { x: 0, y: 100, width: 50, height: 4096 }, + { x: 0, y: 4196, width: 50, height: 4096 }, + { x: 0, y: 8292, width: 50, height: 1808 }, + ]); + // A whole number of device pixels at each ratio, as near 4096 as that allows. + const ratios = [1, 2, 1.25, 1.5, 1.1, 1.3333333730697632, 3]; + expect(ratios.map((ratio) => partHeight(4096, ratio))).toEqual([4096, 2048, 3276, 2730, 3720, 3069, 1365]); + // None just below: as many CSS pixels as fit. + expect(partHeight(4096, Math.SQRT2)).toBe(2896); + }); + + it('captures a clip at once when it fits in a texture, else part by part', async () => { + const shot: object[] = []; + const shoot = async (part: object) => { + shot.push(part); + return encodePng(2, 1, () => [0, 0, 0]); + }; + await captureInParts({ x: 0, y: 0, width: 2, height: 16_384 }, 1, shoot); + expect(shot).toEqual([{ x: 0, y: 0, width: 2, height: 16_384 }]); + shot.length = 0; + const joined = await captureInParts({ x: 0, y: 0, width: 2, height: 8193 }, 2, shoot); + expect(shot.map((p) => (p as { y: number }).y)).toEqual([0, 2048, 4096, 6144, 8192]); + expect(readPngSize(joined)).toEqual({ width: 2, height: 5 }); + }); + + it("reads a PNG's size from its header", () => { + expect(readPngSize(pngHeader(1024, 7))).toEqual({ width: 1024, height: 7 }); + expect(() => readPngSize(Buffer.alloc(3))).toThrow('empty'); + }); +}); + +describe('Designs', () => { + it("reads a PNG's, JPEG's and WebP's type and size from their bytes, and nothing else's", async () => { + const { imageInfo } = await import('../../src/main/shots/imageInfo'); + expect(imageInfo(pngHeader(2880, 5000))).toEqual({ ext: 'png', width: 2880, height: 5000 }); + // A JPEG: SOI, an APP0 segment, then a baseline frame header (height 1080, width 1920). + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x04, 0x38, 0x07, 0x80, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + expect(imageInfo(jpeg)).toEqual({ ext: 'jpg', width: 1920, height: 1080 }); + const riff = (chunk: string, data: number[]) => Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP'), Buffer.from(chunk, 'latin1'), Buffer.alloc(4), Buffer.from(data), Buffer.alloc(10)]); + // Lossy: a frame tag (3 bytes), a start code (3), then 14-bit width and height, little-endian. + expect(imageInfo(riff('VP8 ', [0, 0, 0, 0x9d, 0x01, 0x2a, 0x80, 0x02, 0xe0, 0x01]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + // Lossless: a signature byte, then width − 1 and height − 1 in 14 bits each. + const bits = (640 - 1) | ((480 - 1) << 14); + expect(imageInfo(riff('VP8L', [0x2f, bits & 0xff, (bits >> 8) & 0xff, (bits >> 16) & 0xff, (bits >> 24) & 0xff]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + // Extended: flags (4 bytes), then 24-bit width − 1 and height − 1. + expect(imageInfo(riff('VP8X', [0, 0, 0, 0, 0x7f, 0x02, 0x00, 0xdf, 0x01, 0x00]))).toEqual({ ext: 'webp', width: 640, height: 480 }); + expect(imageInfo(Buffer.from(''))).toBeNull(); + expect(imageInfo(Buffer.from([0x89, 0x50, 0x4e]))).toBeNull(); + }); + + it("takes a design's scale from its name, else its width", async () => { + const { designScale } = await import('../../src/main/shots/designScale'); + expect(designScale('hero@2x.png', 800)).toBe(2); + expect(designScale('Hero @3X.jpg', 800)).toBe(3); + expect(designScale('hero.png', 2880)).toBe(2); + expect(designScale('hero.png', 1440)).toBe(1); + }); +}); diff --git a/test/unit/stitchPngs.test.ts b/test/unit/stitchPngs.test.ts new file mode 100644 index 00000000..1b1c71a0 --- /dev/null +++ b/test/unit/stitchPngs.test.ts @@ -0,0 +1,90 @@ +/** + * Joining PNG captures top to bottom (a page taller than a texture, captured in parts): rows filtered in every way the + * format allows, the first row of each part included, come out as the pixels they were; RGB and RGBA; the first + * part's colour space kept; parts that can't be joined refused. + */ +import { deflateSync } from 'node:zlib'; +import { describe, expect, it } from 'vitest'; +import { stitchPngs } from '../../src/main/shots/png'; +import { decodePng } from '../helpers/decodePng'; + +type Color = (x: number, y: number) => number[]; + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'latin1'); + // The CRC isn't checked when joining: any will do here. + return Buffer.concat([head, data, Buffer.alloc(4)]); +} + +/** Filters a row against the one above it (zeros for the first), as the filter type says. */ +function filterRow(filter: number, row: Buffer, above: Buffer, bpp: number): Buffer { + const out = Buffer.alloc(row.length + 1); + out[0] = filter; + for (let i = 0; i < row.length; i++) { + const [a, b, c] = [i >= bpp ? row[i - bpp] : 0, above[i], i >= bpp ? above[i - bpp] : 0]; + const p = a + b - c; + const paeth = Math.abs(p - a) <= Math.abs(p - b) && Math.abs(p - a) <= Math.abs(p - c) ? a : Math.abs(p - b) <= Math.abs(p - c) ? b : c; + out[i + 1] = (row[i] - [0, a, b, (a + b) >> 1, paeth][filter]) & 0xff; + } + return out; +} + +/** A PNG whose row y is filtered with `filterOf(y)`, and extra chunks before its data. */ +function png(width: number, height: number, channels: 3 | 4, color: Color, filterOf: (y: number) => number, extras: Buffer[] = []): Buffer { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = channels === 4 ? 6 : 2; + let above = Buffer.alloc(width * channels); + const rows: Buffer[] = []; + for (let y = 0; y < height; y++) { + const row = Buffer.alloc(width * channels); + for (let x = 0; x < width; x++) row.set(color(x, y).slice(0, channels), x * channels); + rows.push(filterRow(filterOf(y), row, above, channels)); + above = row; + } + return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', header), ...extras, chunk('IDAT', deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]); +} + +// Colours that change along both axes, so every filter has something to predict. +const color: Color = (x, y) => [(x * 37 + y * 11) & 0xff, (x * 5 + y * 71) & 0xff, (x * y + 7) & 0xff, 128 + ((x + y) & 0x7f)]; + +describe('Joining PNG parts', () => { + it('gives the pixels of the parts, top to bottom, whatever filter each row, a first one too, had', async () => { + const width = 9; + // Three parts, each starting on another filter (none, up, average, paeth, sub…), rows cycling through them all. + const parts = [0, 2, 3, 4].map((start, i) => png(width, 5 + i, 3, (x, y) => color(x, y + 100 * i), (y) => (start + y) % 5)); + const joined = decodePng(await stitchPngs(parts)); + expect([joined.width, joined.height]).toEqual([width, 5 + 6 + 7 + 8]); + let top = 0; + for (const [i, rows] of [5, 6, 7, 8].entries()) { + for (let y = 0; y < rows; y++) { + for (let x = 0; x < width; x++) expect(joined.at(x, top + y)).toEqual([...color(x, y + 100 * i).slice(0, 3), 255]); + } + top += rows; + } + }); + + it('joins RGBA parts, keeping the first part\'s colour space and density', async () => { + const srgb = chunk('sRGB', Buffer.from([0])); + const parts = [png(4, 3, 4, color, () => 3, [srgb]), png(4, 2, 4, (x, y) => color(x, y + 3), () => 4)]; + const bytes = await stitchPngs(parts); + const joined = decodePng(bytes); + for (let y = 0; y < 5; y++) for (let x = 0; x < 4; x++) expect(joined.at(x, y)).toEqual(color(x, y)); + expect(bytes.includes(Buffer.from('sRGB'))).toBe(true); + expect(bytes.indexOf('sRGB')).toBeLessThan(bytes.indexOf('IDAT')); + }); + + it("refuses parts that can't be joined: of another width or format, not a PNG, or damaged", async () => { + await expect(stitchPngs([png(4, 2, 3, color, () => 0), png(5, 2, 3, color, () => 0)])).rejects.toThrow("can't be joined"); + await expect(stitchPngs([png(4, 2, 3, color, () => 0), png(4, 2, 4, color, () => 0)])).rejects.toThrow("can't be joined"); + await expect(stitchPngs([Buffer.from('not a png')])).rejects.toThrow("isn't a PNG"); + const cut = png(4, 3, 3, color, () => 0); + const idat = cut.indexOf('IDAT') + 4; + const short = Buffer.concat([cut.subarray(0, idat - 8), chunk('IDAT', deflateSync(Buffer.alloc(5))), chunk('IEND', Buffer.alloc(0))]); + await expect(stitchPngs([cut, short])).rejects.toThrow('came back damaged'); + }); +}); diff --git a/test/unit/webkitDownload.test.ts b/test/unit/webkitDownload.test.ts new file mode 100644 index 00000000..6b99ee05 --- /dev/null +++ b/test/unit/webkitDownload.test.ts @@ -0,0 +1,106 @@ +/** + * The WebKit build the app downloads (Playwright's): where it goes and comes from, as the pinned playwright-core's + * registry says; downloaded from a stand-in host with its progress, the next mirror tried when one fails, unpacked + * with its program made runnable and its marker written last; nothing left of one that failed; and listed, downloaded + * and removed as a browser of its own. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { installWebKit } from '../../src/main/browsers/webkit/installWebKit'; +import { isInstalled } from '../../src/main/browsers/webkit/isInstalled'; +import { WebKitDownload } from '../../src/main/browsers/webkit'; +import { webkitBuild } from '../../src/main/browsers/webkit/webkitBuild'; +import type { WebKitBuild } from '../../src/main/browsers/webkit/types'; +import type { AppEvent } from '../../src/shared/types'; +import { zipOf } from '../helpers/zipOf'; + +const tmp = mkdtempSync(join(tmpdir(), 'console-editor-webkit-')); + +/** A zip holding a stand-in build: its program (not yet runnable) and a library. */ +const buildZip = (program: string) => + zipOf([ + { name: program, data: Buffer.from('#!/bin/sh\nexit 1\n'), mode: 0o100644 }, + { name: 'lib/libwebkit.so', data: Buffer.alloc(200_000, 7) }, + ]); + +describe('The WebKit build', () => { + let server: Server; + let host: string; + let zip: Buffer; + const asked: string[] = []; + + beforeAll(async () => { + zip = await buildZip('pw_run.sh'); + server = createServer((req, res) => { + asked.push(req.url!); + if (req.url!.startsWith('/missing/')) return void res.writeHead(404).end(); + // Says it is longer than it is: cut short. + if (req.url!.startsWith('/short/')) return void res.writeHead(200, { 'content-length': String(zip.length + 10) }).end(zip); + res.writeHead(200, { 'content-length': String(zip.length) }).end(zip); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + host = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + // Where the app's download of it comes from (read by Playwright's registry as it loads). + process.env.PLAYWRIGHT_DOWNLOAD_HOST = host; + }); + + afterAll(async () => { + delete process.env.PLAYWRIGHT_DOWNLOAD_HOST; + await new Promise((r) => server.close(r)); + rmSync(tmp, { recursive: true, force: true }); + }); + + const build = (name: string, urls: string[]): WebKitBuild => ({ directory: join(tmp, name, 'webkit-1'), executable: join(tmp, name, 'webkit-1', 'pw_run.sh'), urls, version: '26.6' }); + + it("is where the pinned playwright-core's registry puts it, under the app's folder, and comes from its host", async () => { + const found = await webkitBuild(join(tmp, 'builds')); + const { revision, browserVersion } = (JSON.parse(readFileSync(join(__dirname, '../../node_modules/playwright-core/browsers.json'), 'utf8')) as { browsers: Array<{ name: string; revision: string; browserVersion: string }> }).browsers.find((b) => b.name === 'webkit')!; + expect(dirname(found.directory)).toBe(join(tmp, 'builds')); + expect(basename(found.directory)).toMatch(new RegExp(`^webkit-(${revision}|\\d+)$`)); + expect(found.executable.startsWith(found.directory)).toBe(true); + expect(found.version).toBe(browserVersion); + expect(found.urls).toEqual([expect.stringMatching(new RegExp(`^${host}/builds/webkit/\\d+/webkit-.+\\.zip$`))]); + }); + + it('is downloaded with its progress, unpacked, its program made runnable and its marker written last', async () => { + const target = build('ok', [`${host}/missing/webkit.zip`, `${host}/builds/webkit.zip`]); + const progress: Array<[number, number | null]> = []; + await installWebKit(target, (done, total) => progress.push([done, total])); + expect(progress.at(-1)).toEqual([zip.length, zip.length]); + expect(asked.slice(-2)).toEqual(['/missing/webkit.zip', '/builds/webkit.zip']); + expect(statSync(target.executable).mode & 0o777).toBe(0o755); + expect(statSync(join(target.directory, 'lib/libwebkit.so')).size).toBe(200_000); + expect(existsSync(join(target.directory, 'INSTALLATION_COMPLETE'))).toBe(true); + expect(await isInstalled(target)).toBe(true); + }); + + it('leaves nothing of a download that failed, or was cut short', async () => { + const missing = build('missing', [`${host}/missing/a.zip`, `${host}/missing/b.zip`]); + await expect(installWebKit(missing, () => undefined)).rejects.toThrow('The download failed (404)'); + expect(existsSync(missing.directory)).toBe(false); + const short = build('short', [`${host}/short/webkit.zip`]); + await expect(installWebKit(short, () => undefined)).rejects.toThrow(); + expect(existsSync(short.directory)).toBe(false); + expect(await isInstalled(short)).toBe(false); + }); + + it('is listed as a browser of its own, downloaded when asked (its progress announced), and removed', async () => { + const events: AppEvent[] = []; + let changes = 0; + const webkit = new WebKitDownload({ dir: join(tmp, 'app'), send: (event) => events.push(event), changed: () => changes++ }); + const [listed] = await webkit.list(); + expect(listed).toMatchObject({ browser: { id: 'playwright:webkit', name: 'WebKit', engine: 'webkit', command: [] }, downloaded: false, version: expect.stringMatching(/^\d+\.\d+/) }); + await Promise.all([webkit.download(), webkit.download()]); + expect(asked.filter((url) => url.startsWith('/builds/webkit/'))).toHaveLength(1); + expect(events.at(-1)).toEqual({ type: 'browser-download', id: 'playwright:webkit', done: zip.length, total: zip.length }); + expect(changes).toBe(1); + expect((await webkit.list())[0].downloaded).toBe(true); + await webkit.remove(); + expect((await webkit.list())[0].downloaded).toBe(false); + expect(changes).toBe(2); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index 8504b0de..4d867d35 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -35,6 +35,7 @@ "exclude": [ "test/renderer/**/*", "test/fixtures/apps/**/*", - "test/perf/apps/**/*" + "test/perf/apps/**/*", + "test/perf/renderer/**/*" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index c3f0edea..d151ef7e 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -37,6 +37,7 @@ "test/fixtures/sourceMaps.ts", "test/fixtures/esbuildApp.ts", "test/fixtures/apps/**/*", - "test/perf/apps/**/*" + "test/perf/apps/**/*", + "test/perf/renderer/**/*" ] }