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
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,24 @@ jobs:
- name: Install Playwright browsers
run: pnpm --filter @funstack/static exec playwright install chromium

- name: Run e2e tests
- name: Run e2e tests (build)
run: pnpm run test:e2e

- name: Upload Playwright report
- name: Run e2e tests (dev server)
run: pnpm run test:e2e:dev

- name: Upload Playwright report (build)
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
name: playwright-report-build
path: packages/static/playwright-report/
retention-days: 30

- name: Upload Playwright report (dev server)
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report-dev
path: packages/static/playwright-report-dev/
retention-days: 30
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"test": "turbo run test",
"test:run": "turbo run test:run",
"test:e2e": "turbo run test:e2e",
"test:e2e:dev": "turbo run test:e2e:dev",
"typecheck": "turbo run typecheck",
"lint": "oxlint",
"format": "prettier --write --experimental-cli .",
Expand Down
49 changes: 49 additions & 0 deletions packages/static/e2e/playwright-dev.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests-dev",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html", { outputFolder: "../playwright-report-dev" }]],

use: {
trace: "on-first-retry",
},

projects: [
{
name: "single-entry-dev",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4175",
},
testMatch: /\/(dev-server|hydration|client-init)\.spec\.ts$/,
},
{
name: "multi-entry-dev",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:4176",
},
testMatch: /\/multi-entry\.spec\.ts$/,
},
],

webServer: [
{
command: "cd fixture && pnpm vite dev --port 4175 --strictPort",
url: "http://localhost:4175/@vite/client",
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
{
command:
"cd fixture-multi-entry && pnpm vite dev --port 4176 --strictPort",
url: "http://localhost:4176/@vite/client",
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
],
});
48 changes: 48 additions & 0 deletions packages/static/e2e/tests-dev/client-init.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";

test.describe("Client initialization (dev server)", () => {
test("clientInit module runs before React hydration", async ({ page }) => {
await page.goto("/");

// Wait for React hydration to complete
const counter = page.getByTestId("counter");
await counter.click();
await expect(counter).toHaveText("Count: 1");

// Verify client init ran
const clientInitRan = await page.evaluate(() => window.__CLIENT_INIT_RAN__);
expect(clientInitRan).toBe(true);

// Verify client init ran before React hydration
const timestamps = await page.evaluate(() => ({
clientInit: window.__CLIENT_INIT_TIMESTAMP__,
reactHydrated: window.__REACT_HYDRATED_TIMESTAMP__,
}));

expect(timestamps.clientInit).toBeDefined();
expect(timestamps.reactHydrated).toBeDefined();
expect(timestamps.clientInit).toBeLessThanOrEqual(
timestamps.reactHydrated!,
);
});

test("clientInit globals are available during React render", async ({
page,
}) => {
// Listen for console messages to verify no errors
const errors: string[] = [];
page.on("pageerror", (error) => {
errors.push(error.message);
});

await page.goto("/");

// Verify no errors occurred (client init should be available)
await page.waitForLoadState("networkidle");
expect(errors).toEqual([]);

// Verify the global is set
const clientInitRan = await page.evaluate(() => window.__CLIENT_INIT_RAN__);
expect(clientInitRan).toBe(true);
});
});
41 changes: 41 additions & 0 deletions packages/static/e2e/tests-dev/dev-server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from "@playwright/test";

const htmlHeaders = { Accept: "text/html" };

test.describe("Dev server response verification", () => {
test("serves index page with expected HTML structure", async ({
request,
}) => {
const response = await request.get("/", { headers: htmlHeaders });
expect(response.ok()).toBe(true);

const html = await response.text();

// Verify HTML structure
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("<html");
expect(html).toContain("lang=");
expect(html).toContain("<head>");
expect(html).toContain("<body>");

// Verify funstack static app entry marker is present (used for hydration)
expect(html).toContain("__FUNSTACK_APP_ENTRY__");
});

test("loads client entry via Vite module system", async ({ request }) => {
const response = await request.get("/", { headers: htmlHeaders });
const html = await response.text();

// In dev mode, the client entry is loaded through Vite's module system
expect(html).toContain("virtual:vite-rsc/entry-browser");
});

test("includes inline RSC flight data", async ({ request }) => {
const response = await request.get("/", { headers: htmlHeaders });
const html = await response.text();

// In dev mode, RSC payload is inlined in the HTML as __FLIGHT_DATA
// (unlike build mode which uses separate .txt files)
expect(html).toContain("__FLIGHT_DATA");
});
});
59 changes: 59 additions & 0 deletions packages/static/e2e/tests-dev/hydration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";

test.describe("Client-side hydration (dev server)", () => {
test("renders server content", async ({ page }) => {
await page.goto("/");

// Verify server-rendered content is visible
await expect(page.locator("h1")).toHaveText("E2E Test App");
await expect(page.getByTestId("server-rendered")).toHaveText(
"Server rendered content",
);
});

test("client counter component hydrates and displays initial state", async ({
page,
}) => {
await page.goto("/");

const counter = page.getByTestId("counter");
await expect(counter).toBeVisible();
await expect(counter).toHaveText("Count: 0");
});

test("clicking counter button increments the count", async ({ page }) => {
await page.goto("/");

const counter = page.getByTestId("counter");

// Click and verify increment
await counter.click();
await expect(counter).toHaveText("Count: 1");

// Click again
await counter.click();
await expect(counter).toHaveText("Count: 2");

// Click multiple times
await counter.click();
await counter.click();
await expect(counter).toHaveText("Count: 4");
});

test("no JavaScript errors in console", async ({ page }) => {
const errors: string[] = [];

page.on("pageerror", (error) => {
errors.push(error.message);
});

await page.goto("/");

// Wait for hydration to complete by verifying counter is interactive
const counter = page.getByTestId("counter");
await counter.click();
await expect(counter).toHaveText("Count: 1");

expect(errors).toEqual([]);
});
});
65 changes: 65 additions & 0 deletions packages/static/e2e/tests-dev/multi-entry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";

const htmlHeaders = { Accept: "text/html" };

test.describe("Multi-entry dev server response", () => {
test("serves index page with expected HTML structure", async ({
request,
}) => {
const response = await request.get("/", { headers: htmlHeaders });
expect(response.ok()).toBe(true);

const html = await response.text();
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("<html");
expect(html).toContain("__FUNSTACK_APP_ENTRY__");
});

test("serves about page with expected HTML structure", async ({
request,
}) => {
const response = await request.get("/about", { headers: htmlHeaders });
expect(response.ok()).toBe(true);

const html = await response.text();
expect(html).toContain("<!DOCTYPE html>");
expect(html).toContain("<html");
expect(html).toContain("__FUNSTACK_APP_ENTRY__");
});
});

test.describe("Multi-entry page rendering (dev server)", () => {
test("home page renders correct content", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toHaveText("Home Page");
await expect(page.getByTestId("page-id")).toHaveText("home");
});

test("about page renders correct content", async ({ page }) => {
await page.goto("/about");
await expect(page.locator("h1")).toHaveText("About Page");
await expect(page.getByTestId("page-id")).toHaveText("about");
});

test("no JavaScript errors on home page", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => {
errors.push(error.message);
});

await page.goto("/");
await page.waitForLoadState("networkidle");
expect(errors).toEqual([]);
});

test("no JavaScript errors on about page", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => {
errors.push(error.message);
});

await page.goto("/about");
await page.waitForLoadState("networkidle");
expect(errors).toEqual([]);
});
});
1 change: 1 addition & 0 deletions packages/static/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"test": "vitest",
"test:run": "vitest run",
"test:e2e": "playwright test --config e2e/playwright.config.ts",
"test:e2e:dev": "playwright test --config e2e/playwright-dev.config.ts",
"typecheck": "tsc --noEmit"
},
"author": "uhyo <uhyo@uhy.ooo>",
Expand Down
4 changes: 4 additions & 0 deletions turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"test:e2e": {
"dependsOn": ["^build"],
"outputs": []
},
"test:e2e:dev": {
"dependsOn": ["^build"],
"outputs": []
}
}
}