diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df4a3ac..b637d06 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,3 +23,22 @@ jobs:
steps:
- uses: wyvox/action@v1
- run: pnpm lint
+
+ e2e:
+ name: "E2E"
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - uses: wyvox/action@v1
+ - name: Install Playwright browser
+ run: pnpm --filter docs-for-ember-astro exec playwright install --with-deps chromium
+ - name: Run e2e tests
+ run: pnpm --filter docs-for-ember-astro test:e2e
+ - name: Upload report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: docs/playwright-report/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index 807dfea..32cab47 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,11 @@
.env
+.claude/
.env.production
.astro/
.log/
.DS_Store
node_modules/
dist/
+test-results/
+playwright-report/
+playwright/.cache/
diff --git a/docs/e2e/client-rendering.spec.ts b/docs/e2e/client-rendering.spec.ts
new file mode 100644
index 0000000..46db975
--- /dev/null
+++ b/docs/e2e/client-rendering.spec.ts
@@ -0,0 +1,55 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * These tests guard the core promise of ember-astro: that an Ember component
+ * placed in an Astro page with `client:only="ember"` actually mounts and
+ * renders in the browser. Each assertion targets output that ONLY the Ember
+ * component produces (not the surrounding static Astro markup), so if client
+ * rendering breaks, the island stays empty and the test fails.
+ */
+
+test("props: component renders with passed-in args", async ({ page }) => {
+ await page.goto("/examples/props");
+
+ // renders `{{@props.greeting}}, {{@props.who}}`
+ await expect(page.getByText("Hello, World")).toBeVisible();
+});
+
+test("default block content: default slot renders as raw HTML", async ({ page }) => {
+ await page.goto("/examples/default-block-content");
+
+ // Demo renders `{{{@slots.default}}}` — the block content incl. a .
+ const island = page.locator("astro-island[component-export='Demo']");
+ await expect(island).toContainText("block content here");
+ await expect(island.locator("strong", { hasText: "here" })).toBeVisible();
+});
+
+test("named slots: each named slot renders into its fieldset", async ({ page }) => {
+ await page.goto("/examples/named-slots");
+
+ // The .container element only exists once the Ember component has rendered.
+ const container = page.locator(".container");
+ await expect(container).toBeVisible();
+
+ // Legends come from the .gjs template; slot bodies come from Astro slots
+ // (Astro strips the `slot="…"` attribute, so we match the rendered text).
+ for (const legend of ["@slots.one", "@slots.two", "@slots.three"]) {
+ await expect(container.locator("legend", { hasText: legend })).toBeVisible();
+ }
+ for (const body of ["one", "two", "three"]) {
+ await expect(container.getByText(body, { exact: true })).toBeVisible();
+ }
+});
+
+test("nested components: Outer renders and contains Inner", async ({ page }) => {
+ await page.goto("/examples/nested-components");
+
+ // Each component template renders its own fieldset+legend. Scope to the
+ // fieldset whose *direct* legend is "Outer" so we don't also match the
+ // static Astro wrapper