Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
if: ${{ needs.changes.outputs.playwright == 'true' }}
timeout-minutes: 18 # 2024-01-18 avg: 5.0m max: 7.0m
runs-on: ubuntu-24.04
container:
# Keep this tag in sync with EXPECTED_PLAYWRIGHT_VERSION below.
image: mcr.microsoft.com/playwright:v1.62.1-noble
permissions:
contents: read
id-token: write # required for Doppler OIDC
Expand All @@ -49,6 +52,10 @@ jobs:
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: 🔧 Trust Git workspace
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"

- name: Write TOML
run: |
echo "[runtime]" > .marimo.toml
Expand All @@ -61,6 +68,11 @@ jobs:
run: |
sed -i 's/auto_instantiate = false/auto_instantiate = true/' pyproject.toml

- name: 🛠️ Install build tools
run: |
apt-get update
apt-get install --yes --no-install-recommends make

- name: 🔐 Fetch Doppler secrets
# Skip on PRs from forks: id-token write is downgraded to read, so OIDC fails.
# Downstream tokens (TURBO_TOKEN, CODECOV_TOKEN) are optional and tolerate empty.
Expand Down Expand Up @@ -89,26 +101,17 @@ jobs:
run: |
echo "MARIMO_VERSION=$(uv run marimo --version)" >> $GITHUB_ENV

- name: 🎭 Get installed Playwright version
id: playwright-version
- name: 🎭 Verify Playwright image version
working-directory: ./frontend
env:
EXPECTED_PLAYWRIGHT_VERSION: 1.62.1
run: |
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test | grep @playwright | sed 's/.*@//')
echo "Playwright's Version: $PLAYWRIGHT_VERSION"
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV

- name: 📦 Cache playwright binaries
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-2-${{ env.PLAYWRIGHT_VERSION }}

- name: 📥 Install Playwright Browsers
working-directory: ./frontend
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install chromium --with-deps
ACTUAL_PLAYWRIGHT_VERSION=$(pnpm exec playwright --version)
ACTUAL_PLAYWRIGHT_VERSION=${ACTUAL_PLAYWRIGHT_VERSION#Version }
if [[ "$ACTUAL_PLAYWRIGHT_VERSION" != "$EXPECTED_PLAYWRIGHT_VERSION" ]]; then
echo "Playwright package $ACTUAL_PLAYWRIGHT_VERSION does not match image $EXPECTED_PLAYWRIGHT_VERSION" >&2
exit 1
fi

- name: 🎭 Run Playwright tests
working-directory: ./frontend
Expand Down
40 changes: 30 additions & 10 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,41 @@ colors:
on-accent: "#0B68CB"
on-accent-dark: "#B6ECF7"
ring: "#94A3B8"
destructive: "#FF6666"
on-destructive: "#F8FAFC"
error: "#EA5D5D"
on-error: "#F8FAFC"
success: "#66FF7F"
on-success: "#F8FAFC"
action: "#FEF2A5"
action-hover: "#FFF8BB"
on-action: "#946800"
ring-dark: "#2870BD"
destructive: "#E5484D"
destructive-dark: "#72232D"
destructive-hover: "#DC3E42"
destructive-hover-dark: "#8C333A"
destructive-border: "#CE2C31"
destructive-border-dark: "#FF9592"
on-destructive: "#FFFCFC"
on-destructive-dark: "#FFD1D9"
error: "#E5484D"
error-dark: "#72232D"
on-error: "#FFFCFC"
on-error-dark: "#FFD1D9"
success: "#46A758"
success-dark: "#2D5736"
success-hover: "#3E9B4F"
success-hover-dark: "#366740"
success-border: "#2A7E3B"
success-border-dark: "#71D083"
on-success: "#FBFEFB"
on-success-dark: "#C2F0C2"
action: "#FFE629"
action-dark: "#524202"
action-hover: "#FFDC00"
action-hover-dark: "#665417"
action-border: "#9E6C00"
action-border-dark: "#F5E147"
on-action: "#473B1F"
on-action-dark: "#F6EEB4"
link: "#0B68CB"
link-dark: "#479BF5"
link-visited: "#8E4EC6"
link-visited-dark: "#BF9BDF"
stale: "#EBE2CC"
stale-dark: "#3E3720"
stale-dark: "#525342"
code-background: "#FFFFFF"
code-background-dark: "#282C34"
surface: "#FFFFFF"
Expand Down
10 changes: 10 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,16 @@ fe-test:
e2e:
cd frontend; pnpm playwright install; pnpm playwright test

.PHONY: visual-test
# 🖼️ Test visual baselines in the pinned Playwright container
visual-test: fe
./scripts/run-visual-regression.sh

.PHONY: visual-update
# 🖼️ Update visual baselines in the pinned Playwright container
visual-update: fe
./scripts/run-visual-regression.sh --update-snapshots

.PHONY: fe-lint
# 🧹 Lint frontend
fe-lint:
Expand Down
19 changes: 8 additions & 11 deletions frontend/e2e-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,23 @@ The focused visual suite protects shared theme, typography, chrome, and layout b

### Run the visual suite

If frontend files changed, rebuild the frontend from the repository root:
Visual snapshots are captured in the same pinned Playwright container locally
and in CI. On macOS, the script uses the Docker CLI with OrbStack or another
Docker-compatible runtime.

```bash
make fe
```

Run the focused suite from `frontend/`:
From the repository root, build the frontend and run the focused suite:

```bash
pnpm playwright test e2e-tests/visual-regression.spec.ts --project=chromium
make visual-test
```

### Update visual baselines

CAUTION: Generate committed baselines on Ubuntu 24.04. Native macOS images use a different platform suffix.

Run this command from `frontend/`:
Generate committed baselines with the pinned container, not with native
Playwright. From the repository root, run:

```bash
pnpm playwright test e2e-tests/visual-regression.spec.ts --project=chromium --update-snapshots
make visual-update
```

Review every changed PNG. Then run the suite again without `--update-snapshots`.
Expand Down
25 changes: 25 additions & 0 deletions frontend/e2e-tests/playwright-container-proxy.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* Copyright 2026 Marimo. All rights reserved. */

const net = require("node:net");

const port = Number(process.argv[2]);
const upstreamPort = Number(process.argv[3]);

if (!Number.isInteger(port) || !Number.isInteger(upstreamPort)) {
throw new Error(
`Expected integer ports, received ${process.argv[2]} and ${process.argv[3]}`,
);
}

net
.createServer((client) => {
const upstream = net.connect({
host: "host.docker.internal",
port: upstreamPort,
});

client.pipe(upstream).pipe(client);
client.on("error", () => upstream.destroy());
upstream.on("error", () => client.destroy());
})
.listen(port, "127.0.0.1");
47 changes: 47 additions & 0 deletions frontend/e2e-tests/visual-regression.container.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/* Copyright 2026 Marimo. All rights reserved. */

import type { PlaywrightTestConfig } from "@playwright/test";
import { devices } from "@playwright/test";

const config: PlaywrightTestConfig = {
testDir: ".",
testMatch: "visual-regression.spec.ts",
outputDir: "../test-results/visual-regression",
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: false,
forbidOnly: true,
workers: 1,
reporter: "html",
use: {
actionTimeout: 5_000,
navigationTimeout: 10_000,
screenshot: "only-on-failure",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
],
webServer: [
{
command: "node playwright-container-proxy.cjs 2718 2738",
url: "http://127.0.0.1:2718",
reuseExistingServer: true,
},
{
command: "node playwright-container-proxy.cjs 2725 2739",
url: "http://127.0.0.1:2725",
reuseExistingServer: true,
},
],
Comment thread
kirangadhave marked this conversation as resolved.
};

export default config;
84 changes: 84 additions & 0 deletions frontend/e2e-tests/visual-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ function getThemedUrl(app: ApplicationNames, theme: VisualTheme): string {
return url.toString();
}

async function resolveColor(page: Page, value: string): Promise<string> {
return page.evaluate((cssValue) => {
const probe = document.createElement("span");
probe.style.color = cssValue;
document.body.append(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
}, value);
}

async function prepareVisualCase(
page: Page,
visualCase: VisualCase,
Expand Down Expand Up @@ -98,6 +109,79 @@ async function prepareVisualCase(
const focusedInput = page.getByTestId("marimo-plugin-text-input");
await focusedInput.focus();
await expect(focusedInput).toBeFocused();

await expect(page.locator("select").first()).toBeVisible();

const accentColor = await page.locator("html").evaluate(
(element) => getComputedStyle(element).accentColor,
);
const primaryColor = await resolveColor(page, "var(--primary)");
expect(accentColor).toBe(primaryColor);

const semanticAliases =
visualCase.theme === "light"
? [
["--ring", "hsl(215deg 20.2% 65.1%)"],
["--destructive", "var(--red-9)"],
["--destructive-hover", "var(--red-10)"],
["--destructive-border", "var(--red-11)"],
["--destructive-foreground", "var(--red-1)"],
["--error", "var(--red-9)"],
["--error-foreground", "var(--red-1)"],
["--success", "var(--grass-9)"],
["--success-hover", "var(--grass-10)"],
["--success-border", "var(--grass-11)"],
["--success-foreground", "var(--grass-1)"],
["--action", "var(--yellow-9)"],
["--action-hover", "var(--yellow-10)"],
["--action-border", "var(--yellow-11)"],
["--action-foreground", "var(--yellow-12)"],
]
: [
["--ring", "var(--blue-8)"],
["--destructive", "var(--red-6)"],
["--destructive-hover", "var(--red-7)"],
["--destructive-border", "var(--red-11)"],
["--destructive-foreground", "var(--red-12)"],
["--error", "var(--red-6)"],
["--error-foreground", "var(--red-12)"],
["--success", "var(--grass-6)"],
["--success-hover", "var(--grass-7)"],
["--success-border", "var(--grass-11)"],
["--success-foreground", "var(--grass-12)"],
["--action", "var(--yellow-6)"],
["--action-hover", "var(--yellow-7)"],
["--action-border", "var(--yellow-11)"],
["--action-foreground", "var(--yellow-12)"],
];

for (const [semanticToken, sourceColor] of semanticAliases) {
const actualColor = await resolveColor(page, `var(${semanticToken})`);
const expectedColor = await resolveColor(page, sourceColor);
expect(actualColor, semanticToken).toBe(expectedColor);
}

for (const [label, semanticToken] of [
["Success", "--success-border"],
["Warning", "--action-border"],
["Danger", "--destructive-border"],
] as const) {
const button = page.getByRole("button", { name: label });
const colors = await button.evaluate((element) => {
const style = getComputedStyle(element);
return {
background: style.backgroundColor,
border: style.borderColor,
};
});
const expectedBorder = await resolveColor(
page,
`color-mix(in srgb, var(${semanticToken}), transparent 0%)`,
);

expect(colors.border, label).toBe(expectedBorder);
expect(colors.border, label).not.toBe(colors.background);
}
}

for (const visualCase of VISUAL_CASES) {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 42 additions & 0 deletions frontend/src/components/ui/__tests__/button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* Copyright 2026 Marimo. All rights reserved. */

import { describe, expect, it } from "vitest";
import { buttonVariants } from "../button";

describe("buttonVariants", () => {
it.each([
{
variant: "destructive" as const,
expected: [
"bg-destructive",
"text-destructive-foreground",
"border-destructive-border",
"hover:bg-destructive-hover",
],
},
{
variant: "success" as const,
expected: [
"bg-success",
"text-success-foreground",
"border-success-border",
"hover:bg-success-hover",
],
},
{
variant: "warn" as const,
expected: [
"bg-action",
"text-action-foreground",
"border-action-border",
"hover:bg-action-hover",
],
},
])("uses semantic theme colors for $variant", ({ variant, expected }) => {
const classes = buttonVariants({ variant });

expect(classes).toEqual(expect.stringContaining(expected.join(" ")));
expect(classes).not.toMatch(/--(?:red|grass|yellow)-/);
expect(classes).not.toContain("dark:");
});
});
Loading
Loading