docs(roadmap): browser compositor complete — cross-platform GPU shari… #176
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Visual | |
| on: | |
| push: | |
| branches: [master, main] | |
| pull_request: | |
| workflow_dispatch: | |
| env: | |
| CARGO_TERM_COLOR: always | |
| jobs: | |
| # Compile the whole workspace on each desktop OS. This build-verifies the | |
| # per-OS platform backends (today: X11 on Linux; the others use the headless | |
| # fallback until their native backends land). | |
| build: | |
| name: Build (${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest, windows-latest] | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build workspace | |
| run: cargo build --workspace --all-targets | |
| # Run the windowed example against a real (virtual) X server and screenshot | |
| # the result, proving the native X11 backend connects, maps a window, and | |
| # paints. The Forma X11 backend speaks the wire protocol directly, so no | |
| # libX11/libxcb is needed — only Xvfb (a server) and ImageMagick (capture). | |
| visual-x11: | |
| name: Visual test (X11 / Xvfb) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, x11-utils, and a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick x11-utils fonts-dejavu-core | |
| - name: Build the window example | |
| run: cargo build --release -p window | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Launch the Forma window and screenshot it | |
| env: | |
| DISPLAY: ":99" | |
| FORMA_X11_DEBUG: "1" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/window > visual_output/app.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| echo "=== window tree ===" | |
| xwininfo -root -tree -display :99 | head -40 || true | |
| # Capture the whole virtual root (our window maps at the top-left). | |
| import -window root visual_output/forma-x11.png | |
| echo "=== capture ==="; identify visual_output/forma-x11.png | |
| echo "=== app.log ==="; cat visual_output/app.log || true | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshot + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-x11-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Embedded-content viewport (browser-compositor seam): the viewportdemo | |
| # reserves a viewport rect and the app composites CPU-rendered content (a | |
| # cyan/magenta checkerboard standing in for a content process's GPU surface) | |
| # into it. Screenshot the window and assert the viewport region shows that | |
| # content (blue-dominant, multi-color) rather than the dark placeholder, then | |
| # click inside it and confirm the press was forwarded to the content sink with | |
| # viewport-local coordinates (input forwarding). | |
| visual-viewport: | |
| name: Visual test (Viewport / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, x11-utils, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool x11-utils fonts-dejavu-core | |
| - name: Build the viewport demo | |
| run: cargo build --release -p viewportdemo | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Launch the viewport demo and check the composited content | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/viewportdemo > visual_output/viewport.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/forma-viewport.png | |
| echo "=== capture ==="; identify visual_output/forma-viewport.png | |
| echo "=== viewport.log ==="; cat visual_output/viewport.log || true | |
| # The window is 480x360 at the root top-left; the 320x240 viewport is | |
| # centered under the heading, so a region around the window center | |
| # (240,180) lands well inside it. Crop 200x150 there. | |
| convert visual_output/forma-viewport.png -crop 200x150+140+105 +repage \ | |
| visual_output/viewport-region.png | |
| # The checkerboard is cyan (0,255,255) + magenta (255,0,255): both have | |
| # full blue, so the region's mean blue is ~1.0 — unreachable by the dark | |
| # theme background (~0.1) or the slate placeholder (~0.17). | |
| mb=$(convert visual_output/viewport-region.png -format "%[fx:mean.b]" info:) | |
| echo "viewport region mean.b=$mb" | |
| awk "BEGIN{exit !($mb > 0.7)}" | |
| # ...and it must be actual content (a checker), not a single flat color. | |
| colors=$(convert visual_output/viewport-region.png -format '%k' info:) | |
| echo "distinct colors in region: $colors" | |
| [ "$colors" -gt 1 ] | |
| # Input forwarding: click inside the viewport (window center 240,180) | |
| # and confirm the app forwarded a viewport-local pointer press to the | |
| # content sink (the demo logs each forwarded ViewportEvent). | |
| xdotool mousemove 240 180 click 1 | |
| sleep 1 | |
| echo "=== viewport.log after click ==="; cat visual_output/viewport.log || true | |
| grep -q "viewport input:.*PointerDown" visual_output/viewport.log | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshot + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-viewport-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Scroll containers: a tall list clipped to a fixed-height viewport, wheel- | |
| # scrolled with xdotool. Screenshot before/after and assert the visible frame | |
| # changed (content moved) — proving offset + clip work end to end. | |
| visual-scroll: | |
| name: Visual test (Scroll / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, x11-utils, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool x11-utils fonts-dejavu-core | |
| - name: Build the scroll demo | |
| run: cargo build --release -p scrolldemo | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Scroll the list and screenshot before/after | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/scrolldemo > visual_output/scroll.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/scroll-before.png | |
| # Hover over the viewport so the wheel targets the scroll container, | |
| # then wheel down several notches (X11 button 5 = scroll down). | |
| xdotool mousemove 210 200 | |
| sleep 0.5 | |
| for _ in 1 2 3 4 5 6 7 8; do xdotool click 5; done | |
| sleep 1 | |
| import -window root visual_output/scroll-after.png | |
| echo "=== scroll.log ==="; cat visual_output/scroll.log || true | |
| # The visible frame must have changed (the list scrolled within its clip). | |
| diff=$(compare -metric AE visual_output/scroll-before.png visual_output/scroll-after.png null: 2>&1 || true) | |
| echo "changed pixels: $diff" | |
| [ "${diff%%[.e]*}" -gt 2000 ] | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-scroll-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Overlays: a dropdown menu and a modal dialog drawn above the main tree. | |
| # xdotool opens each; the dropdown must change the frame, and the modal must | |
| # darken it (the scrim) — proving the overlay layer + hit-routing work. | |
| visual-overlay: | |
| name: Visual test (Overlays / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, x11-utils, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool x11-utils fonts-dejavu-core | |
| - name: Build the overlay demo | |
| run: cargo build --release -p overlaydemo | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Open a dropdown and a modal, asserting the overlay layer paints | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/overlaydemo > visual_output/overlay.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/overlay-base.png | |
| # Open the dropdown (Menu button center ~ (96,56)). | |
| xdotool mousemove 96 56 click 1 | |
| sleep 1 | |
| import -window root visual_output/overlay-menu.png | |
| # Dismiss by pressing empty space (the non-modal catcher). | |
| xdotool mousemove 440 360 click 1 | |
| sleep 0.5 | |
| # Open the modal dialog (Dialog button center ~ (228,56)). | |
| xdotool mousemove 228 56 click 1 | |
| sleep 1 | |
| import -window root visual_output/overlay-dialog.png | |
| echo "=== overlay.log ==="; cat visual_output/overlay.log || true | |
| # The dropdown must have changed the frame... | |
| menudiff=$(compare -metric AE visual_output/overlay-base.png visual_output/overlay-menu.png null: 2>&1 || true) | |
| echo "menu changed pixels: $menudiff" | |
| [ "${menudiff%%[.e]*}" -gt 1500 ] | |
| # ...and the modal's scrim must darken the whole frame. | |
| mb=$(convert visual_output/overlay-base.png -colorspace Gray -format "%[fx:mean]" info:) | |
| mdg=$(convert visual_output/overlay-dialog.png -colorspace Gray -format "%[fx:mean]" info:) | |
| echo "mean brightness base=$mb dialog=$mdg" | |
| awk "BEGIN{exit !($mdg < $mb)}" | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-overlay-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Clipboard: copy a field's text and paste it back, doubling it. Verifies the | |
| # in-app copy/paste path (Ctrl+A/C/V) via a before/after screenshot diff, and | |
| # the OS round-trip by reading the X11 CLIPBOARD selection back with xclip. | |
| visual-clipboard: | |
| name: Visual test (Clipboard / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, xclip, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool xclip fonts-dejavu-core | |
| - name: Build the clipboard demo | |
| run: cargo build --release -p clipboarddemo | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Copy + paste in the field and check the OS selection | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/clipboarddemo > visual_output/clipboard.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| xdotool key Tab # focus the field ("Clip") | |
| sleep 0.5 | |
| import -window root visual_output/clip-before.png | |
| xdotool key ctrl+a # select all | |
| xdotool key ctrl+c # copy → clipboard + OS selection | |
| sleep 0.5 | |
| xdotool key End # collapse selection, caret at end | |
| xdotool key ctrl+v # paste → "ClipClip" | |
| sleep 1 | |
| import -window root visual_output/clip-after.png | |
| echo "=== clipboard.log ==="; cat visual_output/clipboard.log || true | |
| # The paste must have changed the field (text doubled). The added | |
| # "Clip" glyphs are thin strokes — ~170 differing pixels — whereas a | |
| # failed paste only nudges the caret (<20), so 60 cleanly separates. | |
| diff=$(compare -metric AE visual_output/clip-before.png visual_output/clip-after.png null: 2>&1 || true) | |
| echo "changed pixels: $diff" | |
| [ "${diff%%[.e]*}" -gt 60 ] | |
| # The copy must have populated the X11 CLIPBOARD selection with "Clip" | |
| # (the X11 backend owns the selection and serves SelectionRequest). | |
| sel=$(xclip -o -selection clipboard 2>/dev/null || true) | |
| echo "X11 CLIPBOARD = '$sel'" | |
| [ "$sel" = "Clip" ] | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-clipboard-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # True OS multi-window: the multiwindow example opens two real top-level X11 | |
| # windows (one parent App, two panes over shared state) side by side, a red | |
| # one at x=0 and a blue one at x=340. Screenshot the root and confirm BOTH | |
| # painted — the left region is red-dominant and the right region blue-dominant. | |
| visual-multiwindow: | |
| name: Visual test (Multi-window / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick fonts-dejavu-core | |
| - name: Build the multi-window demo | |
| run: cargo build --release -p multiwindow | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Open two windows and screenshot the root | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/multiwindow > visual_output/multiwindow.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/multiwindow.png | |
| echo "=== multiwindow.log ==="; cat visual_output/multiwindow.log || true | |
| # Crop a patch well inside each window: A at 0,0 and B at 340,0 | |
| # (both 320x240). | |
| convert visual_output/multiwindow.png -crop 280x180+20+20 +repage visual_output/a.png | |
| convert visual_output/multiwindow.png -crop 280x180+360+20 +repage visual_output/b.png | |
| # Command substitution (not `read`, whose non-zero EOF status would | |
| # trip `set -e` since ImageMagick's info: output has no trailing NL). | |
| a=$(convert visual_output/a.png -format "%[fx:mean.r] %[fx:mean.b]" info:) | |
| b=$(convert visual_output/b.png -format "%[fx:mean.r] %[fx:mean.b]" info:) | |
| ar=${a% *}; ab=${a#* } | |
| br=${b% *}; bb=${b#* } | |
| echo "window A region: mean.r=$ar mean.b=$ab" | |
| echo "window B region: mean.r=$br mean.b=$bb" | |
| # A must be red-dominant and B blue-dominant — proving both windows | |
| # exist, side by side, each painting its own pane. | |
| awk "BEGIN{exit !($ar > $ab && $bb > $br)}" | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-multiwindow-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # The calculator demo end to end: render the button grid, then click 7 + 8 = | |
| # via xdotool and confirm the display region changed (0 -> 15). Button centers | |
| # are computed from the fixed 300x460 layout (16px panel padding, 76px display, | |
| # 60px rows with 8px gaps, four 61px columns with 8px gaps). The arithmetic | |
| # itself is covered by the example's unit tests in the main CI test job. | |
| visual-calculator: | |
| name: Visual test (Calculator / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool fonts-dejavu-core | |
| - name: Build the calculator demo | |
| run: cargo build --release -p calculator | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Render, click 7 + 8 =, and check the display changed | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/calculator > visual_output/calculator.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/calc-before.png | |
| # Button centers (logical px == physical at 1x; window at 0,0): | |
| # columns ~x: 46, 115, 184, 253 rows ~y: 134(C..),202(7..), | |
| # 270(4..),338(1..),406(0/./=) | |
| xdotool mousemove 46 202 click 1 # 7 | |
| sleep 0.3 | |
| xdotool mousemove 253 338 click 1 # + | |
| sleep 0.3 | |
| xdotool mousemove 115 202 click 1 # 8 | |
| sleep 0.3 | |
| xdotool mousemove 253 406 click 1 # = | |
| sleep 0.6 | |
| import -window root visual_output/calc-after.png | |
| echo "=== calculator.log ==="; cat visual_output/calculator.log || true | |
| # Crop the display strip (x16..284, y16..92) from both and require it to | |
| # have changed: "0" became "15". | |
| convert visual_output/calc-before.png -crop 268x76+16+16 +repage visual_output/disp-before.png | |
| convert visual_output/calc-after.png -crop 268x76+16+16 +repage visual_output/disp-after.png | |
| diff=$(compare -metric AE visual_output/disp-before.png visual_output/disp-after.png null: 2>&1 || true) | |
| echo "display changed pixels: $diff" | |
| [ "${diff%%[.e]*}" -gt 40 ] | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-calculator-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Tabs + right-click context menu: render the tabsdemo, click the "Settings" | |
| # tab (the body swatch recolors), then right-click the body and confirm a | |
| # context menu appears. Each interaction must change the frame. | |
| visual-tabs: | |
| name: Visual test (Tabs + context menu / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool fonts-dejavu-core | |
| - name: Build the tabs demo | |
| run: cargo build --release -p tabsdemo | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Switch tabs and open a context menu | |
| env: | |
| DISPLAY: ":99" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/tabsdemo > visual_output/tabs.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| import -window root visual_output/tabs-0.png | |
| # Click the middle tab ("Settings"): tabs sit ~y32, three across a | |
| # 360px window — centers near x69 / x180 / x291. | |
| xdotool mousemove 180 32 click 1 | |
| sleep 0.6 | |
| import -window root visual_output/tabs-1.png | |
| # Right-click the body to open the context menu. | |
| xdotool mousemove 180 250 click 3 | |
| sleep 0.6 | |
| import -window root visual_output/tabs-2.png | |
| echo "=== tabs.log ==="; cat visual_output/tabs.log || true | |
| sw=$(compare -metric AE visual_output/tabs-0.png visual_output/tabs-1.png null: 2>&1 || true) | |
| mn=$(compare -metric AE visual_output/tabs-1.png visual_output/tabs-2.png null: 2>&1 || true) | |
| echo "tab-switch changed pixels: $sw" | |
| echo "context-menu changed pixels: $mn" | |
| # Switching tabs recolors a big swatch (large change); opening the menu | |
| # paints a panel of items over the body (also large). | |
| [ "${sw%%[.e]*}" -gt 500 ] | |
| [ "${mn%%[.e]*}" -gt 500 ] | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshots + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-tabs-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Render a real window whose frames are produced on the GPU (forma-gpu) and | |
| # presented through the X11 Surface — the GPU render path wired into the live | |
| # on-screen present loop. Runs under Xvfb + Mesa (surfaceless EGL). | |
| visual-gpu-window: | |
| name: Visual test (GPU window / X11) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, Mesa GL/EGL (+ dev symlinks), ImageMagick, x11-utils, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick x11-utils fonts-dejavu-core \ | |
| libegl-dev libgles-dev libegl-mesa0 libgl1-mesa-dri libgbm-dev | |
| - name: Build the GPU window example (GLES backend) | |
| run: cargo build --release -p gpuwindow --features forma-gpu/gl | |
| - name: Start Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| - name: Launch the GPU window and screenshot it | |
| env: | |
| DISPLAY: ":99" | |
| LIBGL_ALWAYS_SOFTWARE: "1" | |
| GALLIUM_DRIVER: llvmpipe | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/gpuwindow > visual_output/gpuwindow.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 5 | |
| import -window root visual_output/forma-gpu-window.png | |
| echo "=== capture ==="; identify visual_output/forma-gpu-window.png | |
| echo "=== gpuwindow.log ==="; cat visual_output/gpuwindow.log || true | |
| # The GPU path must actually be used (no software fallback message)... | |
| ! grep -q "GPU render unavailable" visual_output/gpuwindow.log | |
| # ...and the window must have painted a non-trivial frame (the dark | |
| # theme background plus the card): more than one distinct color. | |
| colors=$(convert visual_output/forma-gpu-window.png -format '%k' info:) | |
| echo "distinct colors: $colors" | |
| [ "$colors" -gt 1 ] | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload screenshot + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-gpu-window-screenshot | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Cross-process GPU surface seam (browser content path): export a GL texture as | |
| # a dma-buf and re-import it. Build-verified always; functionally verified only | |
| # where the device supports the dma-buf EGL extensions. CI runs software Mesa | |
| # (llvmpipe), which often lacks dma-buf export — so the demo reports UNSUPPORTED | |
| # (exit 2) there and that is accepted; a real failure (exit 1) is not. The full | |
| # functional pass is expected on real GPU hardware. | |
| gpu-dmabuf: | |
| name: GPU dma-buf export/import (Linux) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Mesa GL/EGL | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y libegl-dev libgles-dev libegl-mesa0 libgl1-mesa-dri libgbm-dev | |
| - name: Build the dma-buf spike (GLES backend) | |
| run: cargo build --release -p dmabuftest --features forma-gpu/gl | |
| - name: Run the export/import self-test | |
| env: | |
| LIBGL_ALWAYS_SOFTWARE: "1" | |
| GALLIUM_DRIVER: llvmpipe | |
| run: | | |
| set +e | |
| ./target/release/dmabuftest | tee dmabuf.log | |
| rc=${PIPESTATUS[0]} | |
| echo "exit code: $rc" | |
| # 0 = functional pass, 2 = unsupported on this (software) device — both | |
| # acceptable in CI; 1 = extensions present but pixels wrong (a real bug). | |
| if [ "$rc" -eq 1 ]; then | |
| echo "dma-buf round-trip produced wrong pixels" | |
| exit 1 | |
| fi | |
| if [ "$rc" -ne 0 ] && [ "$rc" -ne 2 ]; then | |
| echo "unexpected exit code $rc" | |
| exit 1 | |
| fi | |
| # Present extension negotiation over the raw X11 socket. The Present extension | |
| # (which flips the DRI3 pixmap to the window) needs no GPU, so dri3probe can | |
| # negotiate it under Xvfb — the CI-verifiable half of the zero-copy GPU-present | |
| # path (the dma-buf Pixmap it flips still needs real GPU hardware). DRI3 itself | |
| # is absent under Xvfb (no DRM), so the probe exits non-zero; we assert Present. | |
| dri3-present: | |
| name: DRI3 Present (X11 negotiation / Xvfb) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb | |
| run: sudo apt-get update && sudo apt-get install -y xvfb | |
| - name: Build dri3probe | |
| run: cargo build --release -p dri3probe | |
| - name: Negotiate the Present extension under Xvfb | |
| run: | | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| XVFB_PID=$! | |
| sleep 2 | |
| set +e | |
| DISPLAY=:99 ./target/release/dri3probe | tee dri3.log | |
| set -e | |
| kill "$XVFB_PID" 2>/dev/null || true | |
| # The Present extension must negotiate (Xvfb supports Present, no GPU). | |
| grep -qE "Present probe: Present [0-9]+\.[0-9]+ available" dri3.log | |
| # Content-process compositing over the CPU shm path (the dual of the GPU | |
| # dma-buf path): the UI process spawns a separate content process that renders | |
| # into a memfd and passes the fd over a Unix socket (SCM_RIGHTS); the UI process | |
| # maps the same memory, composites it into a viewport, and forwards input back, | |
| # which the content process applies by redrawing into the shared buffer. The | |
| # demo self-checks the whole loop — headless and GPU-free, so it runs anywhere. | |
| content-process: | |
| name: Content process (shm IPC + compositing) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build the content-process demo | |
| run: cargo build --release -p contentproc | |
| - name: Run the cross-process content path | |
| run: | | |
| # Capture both streams: the UI process logs to stdout, the content | |
| # process to stderr. | |
| ./target/release/contentproc 2>&1 | tee contentproc.log | |
| # The UI process mapped the content process's buffer, composited it into | |
| # a viewport, and the forwarded input crossed the process boundary. | |
| grep -q "RESULT: PASS" contentproc.log | |
| # ...and the content process dropped privilege: its seccomp sandbox | |
| # blocks opening new sockets while its existing IPC fd keeps working. | |
| grep -q "content: sandbox active (new sockets blocked)" contentproc.log | |
| # Render the window example under a headless wlroots compositor (sway) and | |
| # screenshot it with grim, proving the hand-authored Wayland backend connects, | |
| # binds the globals, creates an xdg-shell window, and presents via wl_shm. | |
| visual-wayland: | |
| name: Visual test (Wayland / sway) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install sway, grim, wtype, seatd, ImageMagick, a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y sway grim wtype seatd imagemagick fonts-dejavu-core | |
| - name: Build the window + textinput examples | |
| run: cargo build --release -p window -p textinput | |
| - name: Run under headless sway and screenshot with grim | |
| run: | | |
| export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdgrt" | |
| mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR" | |
| # Headless wlroots with software (pixman) rendering, no GPU or input. | |
| export WLR_BACKENDS=headless | |
| export WLR_RENDERER=pixman | |
| export WLR_LIBINPUT_NO_DEVICES=1 | |
| # seatd provides a seat without logind; allow this user's group. | |
| sudo seatd -g "$(id -gn)" > seatd.log 2>&1 & | |
| sleep 1 | |
| export LIBSEAT_BACKEND=seatd | |
| printf 'output HEADLESS-1 resolution 1024x768\nexec sleep 600\n' > sway.cfg | |
| sway -c sway.cfg > sway.log 2>&1 & | |
| SWAY_PID=$! | |
| sleep 6 | |
| SOCK=$(ls "$XDG_RUNTIME_DIR" | grep -m1 '^wayland-[0-9]*$' || true) | |
| echo "wayland socket: '$SOCK'" | |
| export WAYLAND_DISPLAY="$SOCK" | |
| export FORMA_WAYLAND_DEBUG=1 | |
| mkdir -p visual_output | |
| # --- render: the window example, screenshotted with grim --- | |
| ./target/release/window > visual_output/wl.log 2>&1 & | |
| WIN_PID=$! | |
| sleep 5 | |
| grim visual_output/forma-wayland.png || echo "grim failed" | |
| kill "$WIN_PID" 2>/dev/null || true | |
| sleep 1 | |
| # --- input: type into the textinput field via wtype (virtual | |
| # keyboard). Tab focuses the field, then we type text — exercising the | |
| # wl_seat keyboard + xkb keymap decode path end to end. --- | |
| ./target/release/textinput > visual_output/wl-input.log 2>&1 & | |
| TXT_PID=$! | |
| sleep 4 | |
| # One wtype session keeps a single virtual keyboard alive for the whole | |
| # sequence (Tab to focus, then type), so the keymap stays valid: Tab, | |
| # then "forma wl", with a delay between keystrokes. | |
| wtype -d 120 -k Tab -- "forma wl" 2>> visual_output/wtype.log; sleep 2 | |
| grim visual_output/forma-wayland-input.png || echo "grim failed" | |
| echo "=== wtype.log ==="; cat visual_output/wtype.log || true | |
| kill "$TXT_PID" 2>/dev/null || true | |
| echo "=== seatd.log ==="; cat seatd.log || true | |
| echo "=== sway.log ==="; cat sway.log || true | |
| echo "=== wl.log ==="; cat visual_output/wl.log || true | |
| echo "=== wl-input.log ==="; cat visual_output/wl-input.log || true | |
| identify visual_output/forma-wayland.png || true | |
| kill "$SWAY_PID" 2>/dev/null || true | |
| - name: Upload screenshot + logs | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-wayland-screenshot | |
| path: | | |
| visual_output/* | |
| sway.log | |
| seatd.log | |
| if-no-files-found: warn | |
| # Verify *real* native input: launch a "click counter" window under Xvfb, | |
| # click it with xdotool, and screenshot before/after to confirm the | |
| # native-event → dispatch → re-present loop works end to end. | |
| interaction-x11: | |
| name: Interaction test (X11 / xdotool) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Xvfb, ImageMagick, xdotool, and a font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y xvfb imagemagick xdotool fonts-dejavu-core | |
| - name: Build the clickdemo + textinput + hoverdemo + textarea examples | |
| # Release so per-event re-rasterization keeps up with input. | |
| run: cargo build --release -p clickdemo -p textinput -p hoverdemo -p textarea | |
| - name: Click + type, screenshotting the results | |
| env: | |
| DISPLAY: ":99" | |
| FORMA_X11_DEBUG: "1" | |
| run: | | |
| mkdir -p visual_output | |
| Xvfb :99 -screen 0 1024x768x24 & | |
| echo "XVFB_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| # --- pointer: click a counting button --- | |
| ./target/release/clickdemo > visual_output/clickdemo.log 2>&1 & | |
| CLICK_PID=$! | |
| sleep 3 | |
| import -window root visual_output/clicks-before.png | |
| xdotool mousemove 320 240 click 1; sleep 1 | |
| xdotool mousemove 320 240 click 1; sleep 1 | |
| import -window root visual_output/clicks-after.png | |
| kill "$CLICK_PID" 2>/dev/null || true | |
| echo "=== clickdemo.log ==="; cat visual_output/clickdemo.log || true | |
| # --- keyboard: focus the field (Tab), type, then move the caret and | |
| # insert mid-string to exercise the caret-aware EditBuffer --- | |
| ./target/release/textinput > visual_output/textinput.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 3 | |
| xdotool key Tab; sleep 1 | |
| xdotool type --delay 80 "Forma" | |
| # Caret now after "Forma"; move left twice and insert -> "ForXYma". | |
| xdotool key Left Left; sleep 0.5 | |
| xdotool type --delay 80 "XY" | |
| sleep 2 | |
| import -window root visual_output/text-typed.png | |
| # Pointer drag-selection: press inside the field near the text start and | |
| # drag right to select the leading characters, then screenshot. | |
| xdotool mousemove 46 90 mousedown 1; sleep 0.3 | |
| xdotool mousemove 96 90; sleep 0.3 | |
| xdotool mouseup 1; sleep 1 | |
| import -window root visual_output/text-dragselect.png | |
| # Keyboard selection: Home, Shift+End to select the whole line. | |
| xdotool key Home; sleep 0.3 | |
| xdotool key shift+End; sleep 1 | |
| import -window root visual_output/text-selected.png | |
| echo "=== textinput.log ==="; cat visual_output/textinput.log || true | |
| kill "$APP_PID" 2>/dev/null || true | |
| # --- hover: move the cursor onto the right button --- | |
| ./target/release/hoverdemo > visual_output/hoverdemo.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 3 | |
| xdotool mousemove 284 70 # center of the right button | |
| sleep 1 | |
| import -window root visual_output/hover.png | |
| kill "$APP_PID" 2>/dev/null || true | |
| # --- multi-line editing: type across lines (Enter = newline), then | |
| # navigate up and select down across lines --- | |
| ./target/release/textarea > visual_output/textarea.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 3 | |
| xdotool key Tab; sleep 1 | |
| xdotool type --delay 60 "Line one" | |
| xdotool key Return | |
| xdotool type --delay 60 "Line two" | |
| xdotool key Return | |
| xdotool type --delay 60 "Line three" | |
| sleep 1 | |
| import -window root visual_output/textarea-typed.png | |
| # Move up two lines to the start, then select down across two lines. | |
| xdotool key Up Up Home; sleep 0.3 | |
| xdotool key shift+Down shift+Down; sleep 1 | |
| import -window root visual_output/textarea-select.png | |
| echo "=== textarea.log ==="; cat visual_output/textarea.log || true | |
| - name: Stop the app and Xvfb | |
| if: always() | |
| run: | | |
| [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true | |
| [ -n "$XVFB_PID" ] && kill "$XVFB_PID" 2>/dev/null || true | |
| - name: Upload before/after screenshots | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-x11-interaction | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Run the windowed example on a real Windows desktop (the runner has one) and | |
| # capture the screen to confirm the native Win32 backend creates a window and | |
| # blits the framebuffer. | |
| visual-windows: | |
| name: Visual test (Windows) | |
| runs-on: windows-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build the window example | |
| run: cargo build --release -p window | |
| - name: Exercise the raw Direct3D 11 (WARP) backend | |
| shell: bash | |
| run: | | |
| cargo run --release -p d3ddemo --features forma-gpu/d3d | tee d3ddemo.log | |
| # WARP is always available on the runner, so a device must come up. | |
| grep -q "D3D11 device: WARP device" d3ddemo.log | |
| - name: Exercise the raw UI Automation provider | |
| shell: bash | |
| run: | | |
| cargo run --release -p uiademo | tee uiademo.log | |
| # The hand-built IRawElementProviderSimple answers via its COM vtable: | |
| # a server-side provider (options 1), a Group control (50026), "Forma". | |
| grep -q 'UIA provider: options=1 controltype=50026 name="Forma"' uiademo.log | |
| # ...and a GPU-rendered frame must read back as the cleared forma blue | |
| # (R=0x60 G=0x9c B=0xff A=0xff), checked tool-free from the printed pixel. | |
| grep -q "D3D11 readback: .* first pixel 96,156,255,255" d3ddemo.log | |
| # ...and a triangle drawn by HLSL shaders must put forma green | |
| # (52,211,153) at the center pixel. | |
| grep -q "D3D11 triangle: .* center pixel 52,211,153,255" d3ddemo.log | |
| # ...and the shared-texture export (dma-buf analog) must run to | |
| # completion — its line appears whether WARP grants the shared handle or | |
| # declines it; absence would mean the COM call crashed. | |
| grep -q "D3D11 shared handle" d3ddemo.log | |
| - name: Launch the Forma window and screenshot the desktop | |
| shell: powershell | |
| run: | | |
| New-Item -ItemType Directory -Force -Path visual_output | Out-Null | |
| $proc = Start-Process -FilePath ".\target\release\window.exe" -PassThru | |
| Start-Sleep -Seconds 5 | |
| Add-Type -AssemblyName System.Windows.Forms, System.Drawing | |
| $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen | |
| $bmp = New-Object System.Drawing.Bitmap($bounds.Width, $bounds.Height) | |
| $gfx = [System.Drawing.Graphics]::FromImage($bmp) | |
| $gfx.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) | |
| $bmp.Save("$PWD\visual_output\forma-windows.png") | |
| Write-Host "Saved screenshot ($($bounds.Width)x$($bounds.Height))" | |
| Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-windows-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Render the same UI under several themes and montage them into one image, | |
| # showing the theme engine + customization builder. | |
| visual-themes: | |
| name: Visual test (Themes) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install ImageMagick + font | |
| run: sudo apt-get update && sudo apt-get install -y imagemagick fonts-dejavu-core | |
| - name: Build the themegallery example | |
| run: cargo build --release -p themegallery | |
| - name: Render themes and montage them | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/themegallery | |
| for i in 0 1 2 3; do | |
| convert -size 360x300 -depth 8 "rgba:theme$i.raw" "t$i.png" | |
| done | |
| montage t0.png t1.png t2.png t3.png -tile 2x2 -geometry +6+6 -background '#222' \ | |
| visual_output/forma-themes.png | |
| identify visual_output/forma-themes.png | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-themes-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Build the web (wasm) target and screenshot it rendering in headless Chrome, | |
| # proving the canvas present path (putImageData of the RGBA framebuffer). | |
| visual-web: | |
| name: Visual test (Web / wasm) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build the wasm module | |
| run: cargo build -p forma-web --target wasm32-unknown-unknown --release | |
| - name: Install a font | |
| run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core | |
| - name: Assemble the web bundle | |
| run: | | |
| mkdir -p web visual_output | |
| cp crates/forma-web/web/index.html web/ | |
| cp target/wasm32-unknown-unknown/release/forma_web.wasm web/ | |
| cp /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf web/font.ttf | |
| ls -l web/ | |
| - name: Serve + screenshot with headless Chrome | |
| run: | | |
| python3 -m http.server 8000 --directory web & | |
| echo "SRV_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| # virtual-time-budget lets the wasm load, the font fetch, and the | |
| # self-driven clicks run before the screenshot is taken. | |
| google-chrome --headless=new --no-sandbox --disable-gpu --hide-scrollbars \ | |
| --window-size=700,420 --virtual-time-budget=15000 \ | |
| --screenshot="$PWD/visual_output/forma-web.png" \ | |
| http://localhost:8000/index.html | |
| echo "Captured:"; identify visual_output/forma-web.png || true | |
| - name: Stop server | |
| if: always() | |
| run: '[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true' | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-web-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Draw a WebGPU triangle (hand-written WGSL, no wgpu) in headless Chrome on | |
| # SwiftShader and screenshot it, proving the browser GPU backend. | |
| visual-webgpu: | |
| name: Visual test (WebGPU / Chrome) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Install ImageMagick | |
| run: sudo apt-get update && sudo apt-get install -y imagemagick | |
| - name: Serve + render the WebGPU triangle with headless Chrome | |
| run: | | |
| mkdir -p visual_output | |
| python3 -m http.server 8000 --directory crates/forma-web/web & | |
| echo "SRV_PID=$!" >> "$GITHUB_ENV" | |
| sleep 2 | |
| # The page reads the rendered pixel back in JS and logs it, so the check | |
| # is deterministic (no canvas-composite/screenshot timing). The triangle | |
| # center must be forma green (52,211,153). | |
| # | |
| # Bringing up WebGPU on the bundled SwiftShader Vulkan ICD headlessly is | |
| # itself intermittently flaky on CI (Chrome occasionally blocklists the | |
| # adapter on a cold start), so retry the *render* a few times — a | |
| # transient adapter miss on one launch usually succeeds on the next. A | |
| # genuine, persistent failure still fails the job. | |
| ok=0 | |
| for attempt in 1 2 3 4 5; do | |
| echo "=== WebGPU attempt $attempt ===" | |
| # WebGPU via Chrome's Dawn on SwiftShader. NB: not --disable-gpu. | |
| google-chrome --headless=new --no-sandbox \ | |
| --enable-unsafe-webgpu --enable-features=Vulkan \ | |
| --use-angle=swiftshader --use-vulkan=swiftshader \ | |
| --enable-logging=stderr --v=1 \ | |
| --window-size=420,300 --virtual-time-budget=20000 \ | |
| --screenshot="$PWD/visual_output/forma-webgpu.png" \ | |
| http://localhost:8000/webgpu.html 2>chrome.log || true | |
| grep -iE "WEBGPU |WEBGPU_" chrome.log | head -5 || true | |
| if grep -q "WEBGPU center=52,211,153" chrome.log; then | |
| ok=1; break | |
| fi | |
| sleep 3 | |
| done | |
| identify visual_output/forma-webgpu.png || true | |
| [ "$ok" = 1 ] | |
| - name: Stop server | |
| if: always() | |
| run: '[ -n "$SRV_PID" ] && kill "$SRV_PID" 2>/dev/null || true' | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-webgpu-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Route a Forma frame through the GPU (forma-gpu: EGL + GLES2) on Mesa's | |
| # software GL and screenshot the read-back, proving the GPU present path. | |
| visual-gpu: | |
| name: Visual test (GPU / GLES via Mesa) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install Mesa (software GL/EGL + Vulkan lavapipe), ImageMagick, font | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y \ | |
| libegl-dev libgles-dev libegl-mesa0 libgl1-mesa-dri libgbm-dev \ | |
| libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools \ | |
| imagemagick fonts-dejavu-core | |
| - name: Build gpudemo with the GL + Vulkan features | |
| run: cargo build --release -p gpudemo --features forma-gpu/gl,forma-gpu/vk | |
| - name: Render through the GPU and convert the readback to PNG | |
| env: | |
| LIBGL_ALWAYS_SOFTWARE: "1" | |
| GALLIUM_DRIVER: llvmpipe | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/gpudemo | tee gpudemo.log | |
| convert -size 420x300 -depth 8 rgba:gpu-out.raw visual_output/forma-gpu.png | |
| # GPU-native geometry: solid rects tessellated + filled on the GPU. | |
| convert -size 420x300 -depth 8 rgba:gpu-rects.raw visual_output/forma-gpu-rects.png | |
| # GPU-native render of the actual widget-tree Scene (render_scene). | |
| convert -size 420x300 -depth 8 rgba:gpu-scene.raw visual_output/forma-gpu-scene.png | |
| # Vulkan-rendered frame (clear) read back from the GPU, raw FFI. | |
| convert -size 420x300 -depth 8 rgba:gpu-vk.raw visual_output/forma-gpu-vk.png | |
| # Vulkan triangle drawn by real SPIR-V shaders, read back, raw FFI. | |
| convert -size 420x300 -depth 8 rgba:gpu-vk-tri.raw visual_output/forma-gpu-vk-tri.png | |
| echo "Captured:"; identify visual_output/forma-gpu*.png | |
| # The raw-FFI Vulkan foundation must reach a device (Mesa lavapipe) and | |
| # create a logical device + graphics queue. | |
| grep -q "Vulkan devices:" gpudemo.log | |
| grep -q "Vulkan init: logical device" gpudemo.log | |
| # ...and allocate a DEVICE_LOCAL color image (offscreen render target). | |
| grep -q "Vulkan image: .* color image bound" gpudemo.log | |
| # ...and build the render-pass + framebuffer over an image view. | |
| grep -q "Vulkan framebuffer: .* framebuffer + render pass" gpudemo.log | |
| # ...and actually submit a clearing render pass, fence-synced (real GPU work). | |
| grep -q "Vulkan clear: .*(fence signaled)" gpudemo.log | |
| # ...and read a GPU-rendered frame back to the CPU (a real screenshot). | |
| grep -q "Vulkan readback: .* bytes" gpudemo.log | |
| # The readback must be the cleared forma blue (R=0x60 G=0x9c B=0xff), not black. | |
| vkpx=$(convert visual_output/forma-gpu-vk.png -format '%[pixel:p{10,10}]' info:) | |
| echo "Vulkan readback pixel: $vkpx" | |
| echo "$vkpx" | grep -q "96,156,255" | |
| # The triangle was drawn by real shaders: the center pixel must be forma | |
| # green (R=0x34 G=0xd3 B=0x99 = 52,211,153), the corner the dark clear. | |
| grep -q "Vulkan triangle: .* bytes" gpudemo.log | |
| tript=$(convert visual_output/forma-gpu-vk-tri.png -format '%[pixel:p{210,150}]' info:) | |
| echo "Vulkan triangle center pixel: $tript" | |
| echo "$tript" | grep -q "52,211,153" | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-gpu-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Verify macOS input: click the centered clickdemo window with `cliclick` | |
| # and screenshot before/after. (Synthetic input on a CI runner can be blocked | |
| # by the accessibility permission model; treated as best-effort.) | |
| interaction-macos: | |
| name: Interaction test (macOS / cliclick) | |
| runs-on: macos-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install cliclick | |
| run: brew install cliclick | |
| - name: Build the clickdemo example (release) | |
| run: cargo build --release -p clickdemo | |
| - name: Click the window and screenshot before/after | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/clickdemo > visual_output/clickdemo-macos.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 4 | |
| screencapture -x visual_output/mac-clicks-before.png | |
| bounds=$(osascript -e 'tell application "Finder" to get bounds of window of desktop') | |
| w=$(echo "$bounds" | awk -F', ' '{print $3}') | |
| h=$(echo "$bounds" | awk -F', ' '{print $4}') | |
| echo "screen ${w}x${h}; clicking center" | |
| cliclick "c:$((w/2)),$((h/2))"; sleep 1 | |
| cliclick "c:$((w/2)),$((h/2))"; sleep 1 | |
| screencapture -x visual_output/mac-clicks-after.png | |
| echo "=== clickdemo-macos.log ==="; cat visual_output/clickdemo-macos.log || true | |
| - name: Stop the app | |
| if: always() | |
| run: '[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true' | |
| - name: Upload screenshots | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-macos-interaction | |
| path: visual_output/* | |
| if-no-files-found: warn | |
| # Run the windowed example on the macOS runner's desktop and screenshot it | |
| # with the built-in `screencapture`, confirming the native Cocoa backend | |
| # creates a window and blits the framebuffer. | |
| visual-macos: | |
| name: Visual test (macOS) | |
| runs-on: macos-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build the window example | |
| run: cargo build --release -p window | |
| - name: Exercise the raw Metal backend | |
| run: | | |
| cargo run --release -p metaldemo --features forma-gpu/mtl | tee metaldemo.log | |
| # A Metal device is always available on the macOS runner. | |
| grep -q "Metal device: " metaldemo.log | |
| # ...and a GPU-rendered frame must read back as the cleared forma blue | |
| # (R=0x60 G=0x9c B=0xff A=0xff), checked tool-free from the printed pixel. | |
| grep -q "Metal readback: .* first pixel 96,156,255,255" metaldemo.log | |
| # ...and a triangle drawn by a compiled .metal shader must put forma | |
| # green (52,211,153) at the center pixel. | |
| grep -q "Metal triangle: .* center pixel 52,211,153,255" metaldemo.log | |
| # ...and a shareable IOSurface (dma-buf analog) must be created with a | |
| # global id — the macOS runner has real IOSurface support. | |
| grep -q "Metal IOSurface: IOSurface .* id [0-9]" metaldemo.log | |
| - name: Launch the Forma window and screenshot the screen | |
| env: | |
| FORMA_COCOA_A11Y: "1" | |
| run: | | |
| mkdir -p visual_output | |
| ./target/release/window > visual_output/macos.log 2>&1 & | |
| echo "APP_PID=$!" >> "$GITHUB_ENV" | |
| sleep 5 | |
| screencapture -x visual_output/forma-macos.png | |
| echo "Captured:"; (sips -g pixelWidth -g pixelHeight visual_output/forma-macos.png || true) | |
| echo "=== macos.log ==="; cat visual_output/macos.log || true | |
| # The custom NSView vends NSAccessibility info via real objc dispatch: | |
| # an accessible AXGroup labelled with the window title ("Forma"). | |
| grep -q "Cocoa a11y: element=true role=AXGroup label=Forma" visual_output/macos.log | |
| - name: Stop the app | |
| if: always() | |
| run: '[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true' | |
| - name: Upload screenshot | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-macos-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Connect to a private session bus with the hand-written D-Bus client (the | |
| # AT-SPI accessibility-bridge foundation) and confirm the SASL handshake + | |
| # Hello assign us a unique name. No display needed. | |
| a11y-dbus: | |
| name: a11y (D-Bus bridge / Linux) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install D-Bus + AT-SPI core | |
| run: sudo apt-get update && sudo apt-get install -y dbus at-spi2-core | |
| - name: Build the a11y demo | |
| run: cargo build --release -p a11ydemo | |
| - name: Connect to the session + AT-SPI buses and read the unique names | |
| run: | | |
| dbus-run-session -- ./target/release/a11ydemo | tee a11y.log | |
| # The session bus assigns every connection a ":1.N" unique name... | |
| grep -qE "D-Bus connected: unique name :1\." a11y.log | |
| # ...and we can reach the separate AT-SPI bus via org.a11y.Bus. | |
| grep -qE "AT-SPI bus connected: unique name :1\." a11y.log | |
| - name: Expose a Forma UI's accessibility tree over AT-SPI and query it | |
| run: | | |
| dbus-run-session -- bash -ec ' | |
| ./target/release/a11ydemo serve > serve.log 2>&1 & | |
| SRV=$! | |
| sleep 2 | |
| cat serve.log | |
| # We must own the well-known name (RequestName -> 1 = primary owner). | |
| grep -q "RequestName org.formaui.A11yDemo -> 1" serve.log | |
| # The root of the Forma UI is a Window -> AT-SPI FRAME role (27), | |
| # with two children (a label and a button). | |
| grep -q "a11y root: role=27 .* children=2" serve.log | |
| # Query our hand-written AT-SPI server the way a screen reader would. | |
| dbus-send --session --print-reply --dest=org.formaui.A11yDemo \ | |
| /org/formaui/a11y org.a11y.atspi.Accessible.GetRole | tee role.log | |
| grep -q "uint32 27" role.log | |
| dbus-send --session --print-reply --dest=org.formaui.A11yDemo \ | |
| /org/formaui/a11y org.freedesktop.DBus.Properties.Get \ | |
| string:org.a11y.atspi.Accessible string:ChildCount | tee cc.log | |
| grep -q "int32 2" cc.log | |
| dbus-send --session --print-reply --dest=org.formaui.A11yDemo \ | |
| /org/formaui/a11y org.freedesktop.DBus.Properties.Get \ | |
| string:org.a11y.atspi.Accessible string:Name | tee name.log | |
| grep -q "Forma" name.log | |
| # ...and the standard introspection still works. | |
| dbus-send --session --print-reply --dest=org.formaui.A11yDemo \ | |
| /org/formaui/a11y org.freedesktop.DBus.Introspectable.Introspect | tee introspect.log | |
| grep -q "org.a11y.atspi.Accessible" introspect.log | |
| kill $SRV 2>/dev/null || true | |
| ' | |
| # Native file dialog over the xdg-desktop-portal FileChooser. No real portal | |
| # backend exists headlessly, so the demo ships a mock portal (owns | |
| # org.freedesktop.portal.Desktop, answers OpenFile, emits the Response signal | |
| # with a canned URI). The client calls dialog::open_file and must receive that | |
| # path — exercising the full hand-written D-Bus FileChooser client round-trip. | |
| file-dialog-portal: | |
| name: File dialog (xdg portal / Linux) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Install D-Bus | |
| run: sudo apt-get update && sudo apt-get install -y dbus | |
| - name: Build the file-dialog demo | |
| run: cargo build --release -p filedialog | |
| - name: Round-trip OpenFile through the mock portal | |
| run: | | |
| dbus-run-session -- bash -ec ' | |
| ./target/release/filedialog serve > serve.log 2>&1 & | |
| SRV=$! | |
| sleep 2 | |
| out=$(./target/release/filedialog) | |
| echo "client: $out" | |
| cat serve.log | |
| # The mock portal served exactly one request... | |
| grep -q "mock portal served one request" serve.log | |
| # ...and the client got the URI it returned, decoded to a path. | |
| [ "$out" = "PICKED: /tmp/forma-pick.txt" ] | |
| kill $SRV 2>/dev/null || true | |
| ' | |
| # Run the native iOS UIKit backend on a booted simulator and confirm it boots, | |
| # builds the window, and renders a frame — a real runtime check of the backend | |
| # the cross-compile job only build-verifies. | |
| visual-ios: | |
| name: Visual test (iOS simulator) | |
| runs-on: macos-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: aarch64-apple-ios-sim | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Build the window example for the iOS simulator | |
| run: cargo build --release -p window --target aarch64-apple-ios-sim | |
| - name: Bundle, boot a simulator, launch, and capture the runtime marker | |
| run: | | |
| mkdir -p visual_output Forma.app | |
| cp target/aarch64-apple-ios-sim/release/window Forma.app/window | |
| cat > Forma.app/Info.plist <<'PLIST' | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"><dict> | |
| <key>CFBundleExecutable</key><string>window</string> | |
| <key>CFBundleIdentifier</key><string>com.karpeleslab.forma</string> | |
| <key>CFBundleName</key><string>Forma</string> | |
| <key>CFBundlePackageType</key><string>APPL</string> | |
| <key>CFBundleShortVersionString</key><string>1.0</string> | |
| <key>CFBundleVersion</key><string>1</string> | |
| <key>LSRequiresIPhoneOS</key><true/> | |
| <key>UIDeviceFamily</key><array><integer>1</integer></array> | |
| <key>MinimumOSVersion</key><string>15.0</string> | |
| </dict></plist> | |
| PLIST | |
| # Use an iPhone simulator the runner already provisioned for its | |
| # installed runtime (avoids device/runtime incompatibility from picking | |
| # an arbitrary device type). | |
| DEV=$(xcrun simctl list devices available -j | python3 -c "import sys,json;d=json.load(sys.stdin)['devices'];ids=[x['udid'] for rt,devs in d.items() if 'iOS' in rt for x in devs if x.get('isAvailable') and 'iPhone' in x['name']];print(ids[0] if ids else '')") | |
| echo "device=$DEV" | |
| [ -n "$DEV" ] | |
| xcrun simctl boot "$DEV" || true | |
| xcrun simctl bootstatus "$DEV" -b || true | |
| xcrun simctl install "$DEV" Forma.app | |
| # Launch the app (it runs forever) and give it a moment to render. | |
| xcrun simctl launch "$DEV" com.karpeleslab.forma || true | |
| sleep 12 | |
| xcrun simctl io "$DEV" screenshot visual_output/forma-ios.png || true | |
| (sips -g pixelWidth -g pixelHeight visual_output/forma-ios.png || true) | |
| # The backend writes a runtime marker into the app's tmp dir; read it | |
| # back from the simulator's data container (app stdout isn't reliably | |
| # captured headlessly). | |
| DATA=$(xcrun simctl get_app_container "$DEV" com.karpeleslab.forma data) | |
| echo "data container: $DATA" | |
| ls -la "$DATA/tmp" || true | |
| cat "$DATA/tmp/forma-ios.txt" || true | |
| # The native UIKit backend booted via UIApplicationMain, created the | |
| # window, and the Forma handler rendered a non-empty frame. | |
| grep -qE "Forma iOS: window shown, framebuffer [1-9][0-9]*x[1-9][0-9]*" "$DATA/tmp/forma-ios.txt" | |
| - name: Upload screenshot + log | |
| uses: actions/upload-artifact@v4 | |
| if: always() | |
| with: | |
| name: forma-ios-screenshot | |
| path: visual_output/*.png | |
| if-no-files-found: warn | |
| # Run the native Android backend on an emulator: build the cdylib, package a | |
| # debug APK by hand (aapt + zipalign + apksigner — no Gradle), install it, and | |
| # confirm via logcat that the NativeActivity presented a Forma frame to the | |
| # ANativeWindow surface. | |
| visual-android: | |
| name: Visual test (Android emulator) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: x86_64-linux-android | |
| - uses: Swatinem/rust-cache@v2 | |
| - uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: "17" | |
| - name: Build the cdylib and assemble a signed debug APK | |
| run: | | |
| set -x | |
| NDK="${ANDROID_NDK_LATEST_HOME:-$ANDROID_NDK_HOME}" | |
| TC="$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin" | |
| export CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER="$TC/x86_64-linux-android21-clang" | |
| cargo build -p androiddemo --target x86_64-linux-android --release | |
| BT=$(ls -d "$ANDROID_HOME"/build-tools/* | sort -V | tail -1) | |
| JAR=$(ls "$ANDROID_HOME"/platforms/*/android.jar | sort -V | tail -1) | |
| echo "build-tools=$BT android.jar=$JAR ndk=$NDK" | |
| cat > AndroidManifest.xml <<'MAN' | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.karpeleslab.forma"> | |
| <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34"/> | |
| <application android:label="Forma" android:hasCode="false" android:debuggable="true"> | |
| <activity android:name="android.app.NativeActivity" android:exported="true"> | |
| <meta-data android:name="android.app.lib_name" android:value="forma_android"/> | |
| <intent-filter> | |
| <action android:name="android.intent.action.MAIN"/> | |
| <category android:name="android.intent.category.LAUNCHER"/> | |
| </intent-filter> | |
| </activity> | |
| </application> | |
| </manifest> | |
| MAN | |
| mkdir -p lib/x86_64 | |
| cp target/x86_64-linux-android/release/libforma_android.so lib/x86_64/ | |
| "$BT/aapt" package -f -M AndroidManifest.xml -I "$JAR" -F base.apk | |
| "$BT/aapt" add base.apk lib/x86_64/libforma_android.so | |
| keytool -genkeypair -keystore debug.ks -storepass android -keypass android \ | |
| -alias d -dname "CN=forma" -keyalg RSA -keysize 2048 -validity 10000 | |
| "$BT/zipalign" -f -p 4 base.apk aligned.apk | |
| "$BT/apksigner" sign --ks debug.ks --ks-pass pass:android --key-pass pass:android \ | |
| --out forma.apk aligned.apk | |
| echo "APK built:"; ls -l forma.apk | |
| - name: Enable KVM | |
| run: | | |
| echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ | |
| | sudo tee /etc/udev/rules.d/99-kvm4all.rules | |
| sudo udevadm control --reload-rules | |
| sudo udevadm trigger --name-match=kvm | |
| - name: Launch on emulator and check logcat | |
| uses: reactivecircus/android-emulator-runner@v2 | |
| with: | |
| api-level: 34 | |
| arch: x86_64 | |
| target: google_apis | |
| force-avd-creation: true | |
| disable-animations: true | |
| script: | | |
| adb install -r forma.apk | |
| adb logcat -c | |
| adb shell am start -n com.karpeleslab.forma/android.app.NativeActivity | |
| sleep 15 | |
| adb logcat -d > logcat.txt || true | |
| grep "Forma Android: window" logcat.txt || true | |
| grep -q "Forma Android: window [0-9]*x[0-9]* presented=true" logcat.txt |