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
86 changes: 86 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: test

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
# Layer 1: platform-neutral logic — widgets' rendered output, theme tokens,
# Notifier, assets, options, SetState batching. Plus formatting, vet on both
# targets, and a WASM build smoke check.
host:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: gofmt
run: test -z "$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
- name: vet (host)
run: go vet ./...
- name: vet (wasm)
run: GOOS=js GOARCH=wasm go vet ./...
- name: test (host, race)
run: go test -race -cover ./...
- name: build (wasm)
run: GOOS=js GOARCH=wasm go build ./...

# Layer 2: the reconciler (element_wasm.go) running as real WASM against a
# real DOM, via wasmbrowsertest in headless Chrome.
wasm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: browser-actions/setup-chrome@v2
- name: allow Chrome's user-namespace sandbox
# ubuntu-24.04 runners restrict unprivileged user namespaces via
# AppArmor, which crashes Chrome's zygote sandbox (wasmbrowsertest runs
# Chrome with the sandbox on and exposes no flag to disable it). Lifting
# the restriction lets the sandbox initialize. `|| true` keeps it a
# no-op on images where the key doesn't exist.
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- name: install wasmbrowsertest
run: |
go install github.com/agnivade/wasmbrowsertest@latest
cp "$(go env GOPATH)/bin/wasmbrowsertest" "$(go env GOPATH)/bin/go_js_wasm_exec"
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: test (wasm runtime)
run: GOOS=js GOARCH=wasm go test -count=1 ./...

# Layer 3: full end-to-end — the testapp built and served by the gutter CLI,
# driven through a real browser by Playwright.
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version: 20
- name: install playwright
working-directory: e2e
run: |
npm install
npx playwright install --with-deps chromium
- name: e2e
working-directory: e2e
run: npx playwright test
- uses: actions/upload-artifact@v7
if: failure()
with:
name: playwright-report
path: e2e/playwright-report
retention-days: 7
27 changes: 21 additions & 6 deletions CLAUDE.md

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Testing Gutter

Gutter's tests are layered to match where the code actually runs. A change is
"safe to ship" only when the layer that exercises it is green.

| Layer | What it covers | Tooling | Command |
|-------|----------------|---------|---------|
| 1. Host unit | Platform-neutral logic: every widget's rendered `*gutter.Host` (tags/styles/attrs/children), theme color tokens, typography mapping, `Notifier`, `AssetURL`, options, `SetState` batching contract, theme presets | `go test` (no browser) | `go test ./...` |
| 2. WASM runtime | The reconciler in `element_wasm.go` against a **real DOM**: mount/update/unmount, attribute/style diffing, keyed + positional `reconcileChildren`, event dispatch + payload, **batched `SetState` coalescing**, dispose lifecycle | `wasmbrowsertest` (headless Chrome) | `GOOS=js GOARCH=wasm go test ./...` |
| 3. End-to-end | Full user flows through a built WASM app served by the gutter CLI: render, batched counter, controlled-input caret, keyed reorder identity, conditional mount/unmount | Playwright (real browser) | `cd e2e && npm test` |

Why three layers: most widget logic is pure CSS generation and is fastest and
most exhaustively tested on the host (layer 1). But the reconciler only exists
under `//go:build js && wasm` and needs a DOM, so it's tested in a browser
(layers 2 and 3). Layer 2 pokes the runtime's internals directly in Go; layer 3
proves the whole stack works the way a product built on Gutter would use it.

## Layer 1 — host unit tests

```sh
go test ./... # fast
go test -race -cover ./... # what CI runs
```

No setup. These run anywhere `go` runs.

## Layer 2 — WASM runtime tests

These are normal `_test.go` files tagged `//go:build js && wasm` (e.g.
`element_wasm_test.go`). The Go toolchain runs a `GOOS=js GOARCH=wasm` test
binary through an exec wrapper named `go_js_wasm_exec`; we point that at
[`wasmbrowsertest`](https://github.com/agnivade/wasmbrowsertest), which loads
the binary into headless Chrome.

One-time setup:

```sh
go install github.com/agnivade/wasmbrowsertest@latest
cp "$(go env GOPATH)/bin/wasmbrowsertest" "$(go env GOPATH)/bin/go_js_wasm_exec"
# ensure $(go env GOPATH)/bin is on PATH
```

Then:

```sh
GOOS=js GOARCH=wasm go test -count=1 ./...
```

Requires a Chrome/Chromium binary on the machine (chromedp finds it
automatically). The harmless `Error: Go program has already exited` line printed
after a passing run is a wasmbrowsertest artifact, not a failure — trust the
`ok` / `PASS`.

## Layer 3 — end-to-end (Playwright)

The app under test is [`e2e/testapp`](e2e/testapp): a deterministic gutter app
whose every interactive surface has a stable selector. Playwright's config
builds the gutter CLI, has it build + serve the testapp on `:8080`
(`e2e/serve.sh`), then drives it.

```sh
cd e2e
npm install
npx playwright install chromium # first time only
npm test
```

To watch it run: `npm run test:headed`.

## Adding tests

- **New widget?** Add a layer-1 test asserting its rendered `*gutter.Host`
(see `widgets/*_test.go` and the `hostOf` helper). If it's a `StatefulWidget`
or imperative (`_wasm.go`), cover it in layer 2 or 3 instead.
- **Reconciler/runtime change?** Add a layer-2 test in `element_wasm_test.go`.
- **New user-facing behavior?** Add a surface to `e2e/testapp` (with a
`data-testid` via the `testID` helper) and a spec in `e2e/tests`.

CI (`.github/workflows/test.yml`) runs all three layers on every push and PR.
44 changes: 44 additions & 0 deletions assets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package gutter

import "testing"

func TestAssetURL(t *testing.T) {
// Restore the default base afterwards: AssetBase is package-global state.
defer SetAssetBase("")

cases := []struct {
name, base, in, want string
}{
{"relative default base", "", "logo.png", "assets/logo.png"},
{"relative strips leading slash off base join", "", "/logo.png", "/logo.png"}, // absolute → unchanged
{"empty path stays empty", "", "", ""},
{"http absolute unchanged", "", "http://cdn/x.png", "http://cdn/x.png"},
{"https absolute unchanged", "", "https://cdn/x.png", "https://cdn/x.png"},
{"protocol-relative unchanged", "", "//cdn/x.png", "//cdn/x.png"},
{"root-absolute unchanged", "", "/static/x.png", "/static/x.png"},
{"data uri unchanged", "", "data:image/svg+xml,<svg/>", "data:image/svg+xml,<svg/>"},
{"custom base", "https://cdn.example.com/v3/", "logo.png", "https://cdn.example.com/v3/logo.png"},
{"custom base no trailing slash gets one", "https://cdn.example.com/v3", "logo.png", "https://cdn.example.com/v3/logo.png"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
SetAssetBase(c.base)
if got := AssetURL(c.in); got != c.want {
t.Fatalf("AssetURL(%q) with base %q = %q, want %q", c.in, c.base, got, c.want)
}
})
}
}

func TestSetAssetBaseNormalization(t *testing.T) {
defer SetAssetBase("")

SetAssetBase("cdn/assets")
if got := AssetBaseURL(); got != "cdn/assets/" {
t.Fatalf("AssetBaseURL() = %q, want trailing slash added", got)
}
SetAssetBase("") // reset
if got := AssetBaseURL(); got != "assets/" {
t.Fatalf("AssetBaseURL() after reset = %q, want %q", got, "assets/")
}
}
2 changes: 1 addition & 1 deletion cmd/gutter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/spf13/cobra"
)

const version = "0.3.0"
const version = "0.4.0"

func main() {
root := &cobra.Command{
Expand Down
10 changes: 5 additions & 5 deletions cmd/gutter/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,11 @@ func runNew(name, modulePath string) error {
}

files := map[string]string{
"main.go": strings.ReplaceAll(mainGoTemplate, "__NAME__", name),
"index.html": strings.ReplaceAll(indexHTMLTemplate, "__NAME__", name),
"go.mod": strings.ReplaceAll(goModTemplate, "__MODULE__", modulePath),
".gitignore": gitignoreTemplate,
"assets/.gitkeep": "",
"main.go": strings.ReplaceAll(mainGoTemplate, "__NAME__", name),
"index.html": strings.ReplaceAll(indexHTMLTemplate, "__NAME__", name),
"go.mod": strings.ReplaceAll(goModTemplate, "__MODULE__", modulePath),
".gitignore": gitignoreTemplate,
"assets/.gitkeep": "",
}
for fname, content := range files {
path := filepath.Join(name, fname)
Expand Down
6 changes: 4 additions & 2 deletions cmd/gutter/style.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ var (
styleAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("#8B5CF6")).Bold(true)
)

func printTitle(s string) { fmt.Println(styleTitle.Render("▶ " + s)) }
func printOK(format string, a ...any) { fmt.Println(styleOK.Render("✓") + " " + fmt.Sprintf(format, a...)) }
func printTitle(s string) { fmt.Println(styleTitle.Render("▶ " + s)) }
func printOK(format string, a ...any) {
fmt.Println(styleOK.Render("✓") + " " + fmt.Sprintf(format, a...))
}
func printWarn(format string, a ...any) {
fmt.Println(styleWarn.Render("!") + " " + fmt.Sprintf(format, a...))
}
Expand Down
7 changes: 7 additions & 0 deletions e2e/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
test-results/
playwright-report/
playwright/.cache/
testapp/dist/
testapp/wasm_exec.js
testapp/app.wasm
78 changes: 78 additions & 0 deletions e2e/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "gutter-e2e",
"version": "0.0.0",
"private": true,
"description": "End-to-end tests for the Gutter framework, driving the testapp in a real browser.",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"install-browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.48.0"
}
}
27 changes: 27 additions & 0 deletions e2e/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Playwright config for the gutter end-to-end suite. It builds and serves the
// testapp (e2e/testapp) via the gutter CLI, then drives it in headless Chromium.
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['list'], ['github']] : 'list',
use: {
baseURL: 'http://localhost:8080',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'bash serve.sh',
url: 'http://localhost:8080',
reuseExistingServer: !process.env.CI,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
13 changes: 13 additions & 0 deletions e2e/serve.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Build the gutter CLI, then build + serve the e2e testapp on :8080.
# Playwright's webServer config launches this and waits for the URL.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"

cli="$(mktemp -d)/gutter"
( cd "$root" && go build -o "$cli" ./cmd/gutter )

cd "$here/testapp"
exec "$cli" run
Loading
Loading